Skip to content

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.sh
3,278 linesCodeBlameRaw
1import {
2 Activity,
3 BarChart3,
4 Building2,
5 MessagesSquare,
6 Bell,
7 BookMarked,
8 BookOpen,
9 Bookmark,
10 Blocks,
11 Bot,
12 Box,
13 Check,
14 ChevronDown,
15 ChevronLeft,
16 ChevronRight,
17 CircleDot,
18 GripVertical,
19 CircleUserRound,
20 Code2,
21 Coins,
22 Compass,
23 CreditCard,
24 Fingerprint,
25 GanttChart,
26 Gauge,
27 GitBranch,
28 GitPullRequest,
29 Globe,
30 History,
31 KanbanSquare,
32 Keyboard,
33 KeyRound,
34 Layers,
35 LayoutDashboard,
36 LayoutGrid,
37 LifeBuoy,
38 ListTree,
39 Lock,
40 LogOut,
41 Mail,
42 MonitorDown,
43 Network,
44 Package,
45 PlayCircle,
46 Plug,
47 Plus,
48 Rocket,
49 Search,
50 ServerCog,
51 Settings,
52 Shapes,
53 Shield,
54 ShieldCheck,
55 Scale,
56 Smile,
57 Sparkles,
58 Store,
59 House,
60 Ticket,
61 TrendingUp,
62 UserRoundKey,
63 Users,
64 UsersRound,
65 Webhook,
66 X,
67 ArrowLeftRight,
68} from "lucide-react";
69import {
70 Fragment,
71 type ReactNode,
72 useCallback,
73 useEffect,
74 useMemo,
75 useRef,
76 useState,
77} from "react";
78import {
79 Link,
80 useFetcher,
81 useLocation,
82 useNavigation,
83 useRouteLoaderData,
84 useSubmit,
85} from "react-router";
86
87import {
88 type Abilities,
89 type ChatSidebarEntry,
90 type InboxCounts,
91 type WorkspaceAgent,
92 type Membership,
93 type RepoMirror,
94 type Spike,
95 type User,
96 hasCodeAccess,
97 mayCreateTeams,
98 shownUsername,
99} from "@g1t/contracts";
100
101import { InMain } from "./landmark";
102import {
103 CommandPalette,
104 type PaletteCommand,
105 PaletteKey,
106 usePaletteShortcut,
107} from "./command-palette";
108import { NotificationsBell } from "./inbox";
109import { useDesktopApp } from "../lib/notify-client";
110import { SpendPill } from "./spend";
111import { PinButton } from "./pin-button";
112import { Hint } from "./ui/hint";
113import {
114 Collapsible,
115 CollapsibleContent,
116 CollapsibleTrigger,
117} from "./ui/collapsible";
118import {
119 Sidebar,
120 SidebarContent,
121 SidebarGroup,
122 SidebarGroupAction,
123 SidebarGroupContent,
124 SidebarGroupLabel,
125 SidebarHeader,
126 SidebarMenu,
127 SidebarMenuAction,
128 SidebarMenuBadge,
129 SidebarMenuButton,
130 SidebarMenuItem,
131 SidebarMobile,
132 SidebarProvider,
133 SidebarSeparator,
134 SidebarTrigger,
135} from "./ui/sidebar";
136import { StatusDot, useSiteStatus } from "./footer";
137import { Logo, MarkGlyph } from "./logo";
138import { SoonPill } from "./ui";
139import { Avatar } from "./ui/avatar";
140import { Skeleton } from "./ui/skeleton";
141import { Button } from "./ui/button";
142import { Separator } from "./ui/separator";
143import {
144 Breadcrumb,
145 BreadcrumbItem,
146 BreadcrumbLink,
147 BreadcrumbList,
148 BreadcrumbPage,
149 BreadcrumbSeparator,
150} from "./ui/breadcrumb";
151import {
152 DropdownMenu,
153 DropdownMenuContent,
154 DropdownMenuGroup,
155 DropdownMenuItem,
156 DropdownMenuLabel,
157 DropdownMenuSeparator,
158 DropdownMenuTrigger,
159} from "./ui/dropdown-menu";
160import { type RoadmapItem, roadmapIn, roadmapItem } from "../lib/roadmap";
161import {
162 type ModeKey,
163 WORKSPACE_SETTINGS_GROUPS,
164 type WorkspaceSettingsPage,
165 homePagePath,
166 modeOf,
167 workspaceSettingsPage,
168} from "../lib/workspace-nav";
169import { AgentsSidebar } from "./agents-mode";
170import { ChatSidebar } from "./chat/sidebar";
171import { FoliosSidebar } from "./folios/sidebar";
172import {
173 Rail,
174 ShortcutsDialog,
175 WorkspaceSwitcher,
176 sidebarKeyLabel,
177} from "./rail";
178import { BottomBar, isConversation, useVisualViewport } from "./mobile";
179import { useChatSidebar } from "./chat/actions";
180import { unreadTotals } from "../lib/chat";
181import {
182 SETTINGS_CAPABILITY,
183 type ViewerAccess,
184 seesSettings,
185} from "../lib/access";
186import { projectPages } from "../lib/chrome";
187import {
188 ACCOUNT_SETTINGS,
189 type AccountSettingsPage,
190 FIRST_SETTINGS_PAGE,
191 accountSettingsPage,
192} from "../lib/account-settings";
193import { type PinnableApp, sidebarCookie } from "../lib/apps";
194import { REASON_FILTERS, inboxReason, inboxView } from "../lib/inbox";
195import { GithubMark } from "./github";
196import { useInviteOnly } from "../lib/registration";
197import { STATUS_URL, statusTitle } from "../lib/status";
198import { type ShortcutProject, movedPin, recentWith } from "../lib/pins";
199import type { AccountMenuData } from "../routes/settings-menu-json";
200import { useLiveBadges } from "../lib/notify-client";
201import { OwnPresenceDot, OwnPresenceItems, StatusDialog } from "./presence";
202import { THEME_COMMANDS, ThemeMenuSwitch } from "./theme-switch";
203
204/**
205 * What the sidebar needs, worked out by the root loader. For a visitor who
206 * is not signed in there is no workspace, no projects and no usage: only
207 * the project being looked at, if they can see it.
208 */
209export type ShellData = {
210 /** The workspace the sidebar is about: the one being looked at, or their first. */
211 workspace: Membership | null;
212 /** All its projects, by name, for the palette: `name` is the slug in their address. */
213 repos: ShortcutProject[];
214 /** The person's pinned projects in it, in their order (lib/pins.ts). */
215 pinned?: ShortcutProject[];
216 /** What they opened there last, latest first, leaving out the pinned. */
217 recent?: ShortcutProject[];
218 /** Repositories shared with them in workspaces they do not belong to. */
219 shared?: { namespace: string; name: string; isPrivate: boolean }[];
220 /** The project being looked at, if any, whoever owns it. */
221 repo: {
222 namespace: string;
223 name: string;
224 member: boolean;
225 issues: number;
226 pulls: number;
227 } | null;
228 /** Where the workspace stands against its usage limit, if billing is on. */
229 limit: {
230 exposureMicros: number;
231 ceilingMicros: number | null;
232 state: "ok" | "warning" | "stopped";
233 comped: boolean;
234 } | null;
235 /** Whether g1t charges nothing for now, while it is being built out. */
236 free?: boolean;
237 /** What its agents have cost since the start of the month. */
238 /** This month's usage: charged, or at cost while g1t is free. */
239 monthUsageMicros: number | null;
240 /** Whether new compute is paused (a spend spike or a hold), and whether the viewer can answer it. */
241 compute?: {
242 paused: string | null;
243 spike: Spike | null;
244 owner: boolean;
245 } | null;
246 /** What is unread in their notifications, for the rail and the bell. Absent for a visitor. */
247 inbox?: InboxCounts | null;
248 /**
249 * Chat, for the rail's badge: what is unread, and the
250 * starred and latest conversations. Null when chat did not answer in time.
251 */
252 chat?: {
253 unread: number;
254 mentions: number;
255 starred?: ChatSidebarEntry[];
256 recent?: ChatSidebarEntry[];
257 } | null;
258 /** The workspace's agents, g1t first, for the Agents sidebar; null when not known. */
259 agents?: ShellAgent[] | null;
260 /** The apps this person pinned to their rail in the workspace (lib/apps.ts). */
261 pins?: PinnableApp[];
262};
263
264/** An agent as the shell lists it. */
265export type ShellAgent = Pick<
266 WorkspaceAgent,
267 | "id"
268 | "handle"
269 | "display_name"
270 | "avatar"
271 | "avatar_seed"
272 | "role"
273 | "status"
274 | "title"
275 | "builtin"
276> & {
277 look?: WorkspaceAgent["look"];
278 scope?: WorkspaceAgent["scope"];
279 /** The visible teams it is on (team memberships); absent when they couldn't be read. */
280 teams?: { slug: string; name: string }[];
281};
282
283/** Whether `pathname` is `to` or a page under it (`end`: only `to` itself), the query left aside. */
284function under(pathname: string, to: string, end = false): boolean {
285 const path = to.split("?")[0]!;
286 return (
287 pathname === path ||
288 (!end && pathname.startsWith(path.endsWith("/") ? path : `${path}/`))
289 );
290}
291
292/**
293 * Where a row is current and where it is on its way to. A row's page is
294 * current under its own address and any `also` prefix, unless the list
295 * says itself (`current`). Rows that differ by query alone (Notifications'
296 * views and reasons) are pending only when the page on its way is that
297 * row's, query and all.
298 */
299function useRowState(
300 to: string,
301 {
302 end,
303 also,
304 current: lit,
305 }: { end?: boolean; also?: string | string[]; current?: boolean },
306) {
307 const { pathname } = useLocation();
308 const going = useNavigation().location;
309 const current =
310 lit ??
311 (under(pathname, to, end) ||
312 [also ?? []].flat().some((prefix) => under(pathname, prefix)));
313 const pending =
314 going != null &&
315 !current &&
316 (lit === undefined
317 ? under(going.pathname, to, end)
318 : `${going.pathname}${going.search}` === to);
319 return { current, pending };
320}
321
322/**
323 * One row of a mode's sidebar: an icon, its words, how many it lists
324 * (`count`) and, for a row that opens a list of its own (`drill`), a
325 * chevron, always or on hover.
326 */
327function SidebarLink({
328 to,
329 icon,
330 end,
331 count,
332 also,
333 drill,
334 current: lit,
335 action,
336 bare = false,
337 children,
338}: {
339 to: string;
340 icon: ReactNode;
341 end?: boolean;
342 count?: number;
343 /** Other path prefixes under which this link is the current one. */
344 also?: string | string[];
345 /** It opens a list of its own: a chevron says so, always or on hover. */
346 drill?: boolean | "hover";
347 /** Whether it is the current row, when the list works that out itself. */
348 current?: boolean;
349 /** A small button at the row's end, beside the words (a pin). */
350 action?: ReactNode;
351 /** The row alone, for a list item that is drawn by the list (a pinned project, which drags). */
352 bare?: boolean;
353 children: ReactNode;
354}) {
355 const { current, pending } = useRowState(to, { end, also, current: lit });
356 const Item = bare ? Fragment : SidebarMenuItem;
357 return (
358 <Item>
359 <SidebarMenuButton
360 asChild
361 isActive={current}
362 isPending={pending}
363 className={action ? "pr-7" : undefined}
364 >
365 <Link
366 to={to}
367 prefetch="intent"
368 aria-current={current ? "page" : undefined}
369 >
370 {icon}
371 <span className="min-w-0 grow truncate">{children}</span>
372 {count != null && count > 0 && (
373 <SidebarMenuBadge>{count}</SidebarMenuBadge>
374 )}
375 {drill && (
376 <ChevronRight
377 aria-hidden="true"
378 className={`-mr-0.5 size-3.5! shrink-0 text-faint transition-opacity ${drill === "hover" ? "opacity-0 group-hover/menu-button:opacity-100 group-focus-visible/menu-button:opacity-100" : ""}`}
379 />
380 )}
381 </Link>
382 </SidebarMenuButton>
383 {action}
384 </Item>
385 );
386}
387
388/**
389 * Something a project will have, shown where it will live so people can
390 * see where g1t is going. Not a link: nothing is behind it yet.
391 */
392function SidebarSoon({
393 icon,
394 children,
395 about,
396}: {
397 icon: ReactNode;
398 children: ReactNode;
399 about: string;
400}) {
401 return (
402 <SidebarMenuItem>
403 <Hint label={about} side="right">
404 <SidebarMenuButton asChild variant="faint">
405 <div aria-disabled="true" className="cursor-default">
406 {icon}
407 <span className="min-w-0 grow truncate">{children}</span>
408 <span className="sr-only">{about}</span>
409 <SoonPill />
410 </div>
411 </SidebarMenuButton>
412 </Hint>
413 </SidebarMenuItem>
414 );
415}
416
417/**
418 * A page that is coming, as a link to its roadmap page: faint, with Soon,
419 * and marked current on any of its section's Soon pages.
420 */
421function SidebarSoonLink({
422 to,
423 also,
424 icon,
425 about,
426 current: lit,
427 children,
428}: {
429 to: string;
430 also?: string[];
431 icon: ReactNode;
432 about: string;
433 /** Whether it is the current row, when the list works that out itself. */
434 current?: boolean;
435 children: ReactNode;
436}) {
437 const { current, pending } = useRowState(to, { also, current: lit });
438 return (
439 <SidebarMenuItem>
440 <Hint label={about} side="right">
441 <SidebarMenuButton
442 asChild
443 variant="faint"
444 isActive={current}
445 isPending={pending}
446 >
447 <Link
448 to={to}
449 prefetch="intent"
450 aria-current={current ? "page" : undefined}
451 >
452 {icon}
453 <span className="min-w-0 grow truncate">{children}</span>
454 <SoonPill />
455 </Link>
456 </SidebarMenuButton>
457 </Hint>
458 </SidebarMenuItem>
459 );
460}
461
462/** Icons for what spans projects. */
463const WORKSPACE_ICONS: Record<string, ReactNode> = {
464 board: <KanbanSquare size={15} />,
465 roadmap: <GanttChart size={15} />,
466 teams: <UsersRound size={15} />,
467 packages: <Package size={15} />,
468 fleet: <Bot size={15} />,
469};
470
471/** The Soon pages of a section, as paths, so its link is current on them. */
472function soonPaths(base: string, section: RoadmapItem["section"]): string[] {
473 return roadmapIn(section).map((item) => `${base}/soon/${item.key}`);
474}
475
476/** A titled group of rows: its name in small capitals, what it makes at the end, and its menu. */
477function NavGroup({
478 title,
479 action,
480 className,
481 children,
482}: {
483 title: string;
484 /** A small button at the label's end, in its row: what the group makes (a `SidebarGroupAction` with `relative top-auto right-auto`). */
485 action?: ReactNode;
486 className?: string;
487 children: ReactNode;
488}) {
489 return (
490 <SidebarGroup className={className}>
491 <div className="flex items-center justify-between gap-2 pr-1">
492 <SidebarGroupLabel asChild>
493 <h2>{title}</h2>
494 </SidebarGroupLabel>
495 {action}
496 </div>
497 <SidebarGroupContent>
498 <SidebarMenu>{children}</SidebarMenu>
499 </SidebarGroupContent>
500 </SidebarGroup>
501 );
502}
503
504/** Rows with no name over them: the first of a list, or a run between rules. */
505function NavRows({
506 className,
507 children,
508}: {
509 className?: string;
510 children: ReactNode;
511}) {
512 return (
513 <SidebarGroup className={className}>
514 <SidebarGroupContent>
515 <SidebarMenu>{children}</SidebarMenu>
516 </SidebarGroupContent>
517 </SidebarGroup>
518 );
519}
520
521/** What a workspace is called where people read it: its display name, else its slug. */
522function displayName(membership: Membership): string {
523 return membership.name?.trim() || membership.slug;
524}
525
526/**
527 * The fine print, as one quiet row at the foot of the account menu and the
528 * visitor's panel: each link a full-height target, left-aligned. In the
529 * menu each is a menu item, so the arrow keys reach it.
530 */
531const LEGAL_LINKS: [string, string][] = [
532 ["Policies", "/policies"],
533 ["Privacy", "/policies/privacy"],
534 ["Security", "/security"],
535];
536const LEGAL_LINK =
537 "flex h-7 items-center rounded px-1.5 text-xs text-faint outline-none transition-colors";
538
539function MenuLegalRow() {
540 return (
541 <div
542 role="group"
543 aria-label="Legal"
544 className="flex items-center gap-0.5 px-0.5 pt-0.5"
545 >
546 {LEGAL_LINKS.map(([label, to], index) => (
547 <span key={to} className="flex items-center gap-0.5">
548 {index > 0 && (
549 <span
550 aria-hidden="true"
551 className="text-[0.625rem] text-line-strong"
552 >
553 ·
554 </span>
555 )}
556 <DropdownMenuItem
557 asChild
558 className={`${LEGAL_LINK} py-0 data-highlighted:bg-line data-highlighted:text-fg`}
559 >
560 <Link to={to}>{label}</Link>
561 </DropdownMenuItem>
562 </span>
563 ))}
564 </div>
565 );
566}
567
568/** Status's row: the live dot and two or three words, fetched when the menu opens. */
569function StatusSummary({ open }: { open: boolean }) {
570 const status = useSiteStatus(open);
571 const words = statusTitle(status);
572 return (
573 <span className="ml-auto flex min-w-0 items-center gap-1.5 pl-2 text-xs text-faint">
574 {/* The words say it; the dot's own words for screen readers would repeat them. */}
575 <span aria-hidden="true" className="flex">
576 <StatusDot state={status?.overall.state ?? null} />
577 </span>
578 {words ? (
579 <span className="truncate">{words}</span>
580 ) : (
581 <Skeleton className="h-3 w-20" />
582 )}
583 </span>
584 );
585}
586
587/** A menu row: an icon, words, and whatever sits at its end. */
588const MENU_ROW = "h-9 gap-2.5 px-2.5 text-[0.8125rem]";
589
590/**
591 * Your account, at the foot of the rail (or in the header, before you have
592 * a workspace): who you are and your status, your profile and settings,
593 * and help: the documentation, support, status and the keyboard's
594 * shortcuts; then signing out and the fine print.
595 */
596function AccountMenu({
597 user,
598 side = "right",
599}: {
600 user: User;
601 side?: "right" | "bottom";
602}) {
603 const submit = useSubmit();
604 const [open, setOpen] = useState(false);
605 const [keys, setKeys] = useState(false);
606 // Inside the desktop app there is nothing to get.
607 const inApp = useDesktopApp();
608 // Your status's dialog: outside the menu, so the menu closes behind it.
609 const [editing, setEditing] = useState(false);
610 // Name, primary address and invites left: asked for once, as soon as the
611 // pointer or focus reaches the button, so they are there when it opens.
612 const details = useFetcher<AccountMenuData | null>({ key: "account-menu" });
613 const prefetch = () => {
614 if (details.state === "idle" && details.data === undefined)
615 details.load("/settings/menu.json");
616 };
617 useEffect(() => {
618 if (open) prefetch();
619 // eslint-disable-next-line react-hooks/exhaustive-deps
620 }, [open]);
621 const me = details.data ?? null;
622 // Still on its way: shapes where the words will be, the same size.
623 const loading = details.data === undefined && details.state !== "idle";
624 // While anyone can sign up, listed once the data says there are invites to look back on.
625 const invitesListed = useInviteOnly() || me?.invites_page === true;
626 const profile = `/u/${user.username}`;
627 return (
628 <>
629 <DropdownMenu open={open} onOpenChange={setOpen}>
630 {/* The avatar alone, with a dot that says how others see you (components/presence.tsx). */}
631 <Hint
632 label={side === "right" ? "Your account" : undefined}
633 side="right"
634 >
635 <DropdownMenuTrigger
636 aria-label={`Account menu for ${shownUsername(user)}`}
637 onPointerEnter={prefetch}
638 onFocus={prefetch}
639 className={
640 side === "right"
641 ? // At the foot of the rail: a 40px rounded square, as the rail's icons are.
642 "relative flex size-10 items-center justify-center rounded-[10px] outline-none transition-colors hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:bg-sidebar-accent"
643 : "relative flex rounded-full outline-none transition-transform hover:scale-[1.04] focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:ring-2 data-[state=open]:ring-line-strong"
644 }
645 >
646 <Avatar
647 name={user.username}
648 image={user.avatar}
649 size={side === "right" ? 40 : 28}
650 square={side === "right"}
651 />
652 <OwnPresenceDot
653 ring={side === "right" ? "var(--color-rail)" : "var(--color-bg)"}
654 className={
655 side === "right"
656 ? "absolute -right-1 -bottom-1"
657 : "absolute -right-0.5 -bottom-0.5"
658 }
659 />
660 </DropdownMenuTrigger>
661 </Hint>
662 <DropdownMenuContent
663 align="end"
664 side={side}
665 sideOffset={side === "right" ? 14 : 6}
666 collisionPadding={8}
667 className="w-70 p-1.5"
668 >
669 {/* Who is signed in, and a way to their profile. */}
670 <DropdownMenuItem asChild className="gap-3 px-2 py-2">
671 <Link
672 to={profile}
673 aria-label={`${me?.name ?? shownUsername(user)} (@${shownUsername(user)}), your profile`}
674 >
675 <Avatar name={user.username} image={user.avatar} size={36} />
676 <span
677 className="flex min-w-0 grow flex-col leading-tight"
678 aria-busy={loading}
679 >
680 {loading ? (
681 <Skeleton className="my-0.75 h-3.5 w-28" />
682 ) : (
683 <span className="truncate text-sm font-medium text-fg">
684 {me?.name ?? shownUsername(user)}
685 </span>
686 )}
687 <span className="truncate font-mono text-xs text-muted">
688 @{shownUsername(user)}
689 </span>
690 {loading ? (
691 <Skeleton className="mt-1 h-3 w-40" />
692 ) : (
693 me?.email && (
694 <span className="mt-0.5 truncate text-xs text-faint">
695 {me.email}
696 </span>
697 )
698 )}
699 </span>
700 </Link>
701 </DropdownMenuItem>
702 <DropdownMenuSeparator className="my-1.5" />
703 {/* Your status, away, and pausing notifications. */}
704 <DropdownMenuGroup>
705 <OwnPresenceItems
706 className="min-h-9 py-1.5 text-[0.8125rem]"
707 onEdit={() => setEditing(true)}
708 />
709 </DropdownMenuGroup>
710 <DropdownMenuSeparator className="my-1.5" />
711 <DropdownMenuGroup>
712 <DropdownMenuItem asChild className={MENU_ROW}>
713 <Link to={profile}>
714 <CircleUserRound />
715 Your profile
716 </Link>
717 </DropdownMenuItem>
718 <DropdownMenuItem asChild className={MENU_ROW}>
719 <Link to={FIRST_SETTINGS_PAGE}>
720 <Settings />
721 Your settings
722 </Link>
723 </DropdownMenuItem>
724 {invitesListed && (
725 <DropdownMenuItem asChild className={MENU_ROW}>
726 <Link to="/settings/invites">
727 <Ticket />
728 {ACCOUNT_SETTINGS.invites.title}
729 {loading && (
730 <Skeleton className="ml-auto h-4 w-12 rounded-full" />
731 )}
732 {me?.invites_left != null && (
733 <span className="ml-auto rounded-full bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
734 {me.invites_left} left
735 </span>
736 )}
737 </Link>
738 </DropdownMenuItem>
739 )}
740 </DropdownMenuGroup>
741 <DropdownMenuSeparator className="my-1.5" />
742 {/* Auto, Light or Dark, switched in place (lib/theme.ts). */}
743 <ThemeMenuSwitch />
744 <DropdownMenuSeparator className="my-1.5" />
745 <DropdownMenuGroup>
746 <DropdownMenuItem asChild className={MENU_ROW}>
747 <a href="https://docs.g1t.sh/">
748 <BookOpen />
749 Documentation
750 </a>
751 </DropdownMenuItem>
752 <DropdownMenuItem asChild className={MENU_ROW}>
753 <Link to="/support">
754 <LifeBuoy />
755 Support
756 </Link>
757 </DropdownMenuItem>
758 <DropdownMenuItem asChild className={MENU_ROW}>
759 <a href={STATUS_URL}>
760 <Activity />
761 Status
762 <StatusSummary open={open} />
763 </a>
764 </DropdownMenuItem>
765 <DropdownMenuItem
766 className={MENU_ROW}
767 onSelect={() => setKeys(true)}
768 >
769 <Keyboard />
770 Keyboard shortcuts
771 <PaletteKey className="ml-auto font-sans text-xs text-faint" />
772 </DropdownMenuItem>
773 {!inApp && (
774 <DropdownMenuItem asChild className={MENU_ROW}>
775 <Link to="/download">
776 <MonitorDown />
777 Get the desktop app
778 </Link>
779 </DropdownMenuItem>
780 )}
781 </DropdownMenuGroup>
782 <DropdownMenuSeparator className="my-1.5" />
783 {/* Submitted from here: the menu closes on select, and a button
784 that has left the page cannot submit a form. */}
785 <DropdownMenuItem
786 className={MENU_ROW}
787 onSelect={() => submit(null, { method: "post", action: "/logout" })}
788 >
789 <LogOut />
790 Sign out
791 </DropdownMenuItem>
792 <DropdownMenuSeparator className="my-1.5" />
793 <MenuLegalRow />
794 </DropdownMenuContent>
795 </DropdownMenu>
796 <StatusDialog open={editing} onOpenChange={setEditing} />
797 <ShortcutsDialog open={keys} onOpenChange={setKeys} />
798 </>
799 );
800}
801
802/** A project's settings pages, which the project's menu drills into. */
803const REPO_SETTINGS_PAGE = /^\/([^/]+)\/([^/-][^/]*)\/settings(\/|$)/;
804
805/**
806 * The sidebar's lists are a stack, the way a phone pushes a screen: one
807 * drilled into slides in from the right over the full width, while the one
808 * it covers drifts a quarter of the way left and fades, both on one long
809 * ease-out, the fade quicker than the move so the two never blur together.
810 * Going back reverses it.
811 */
812const LAYER =
813 "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";
814/** One list, filling its layer. */
815const PANEL = "h-full overflow-y-auto px-1 pt-1 pb-4 [scrollbar-width:thin]";
816
817/** One list in the stack: what it is, and its rows. */
818type Level = { key: string; node: ReactNode };
819
820/** True once the page has hydrated, so what the server drew never animates in. */
821function useHydrated(): boolean {
822 const [hydrated, setHydrated] = useState(false);
823 useEffect(() => setHydrated(true), []);
824 return hydrated;
825}
826
827/**
828 * Drilling in and out of the sidebar's lists. `trail` is the way from the
829 * main list to the one shown, worked out from the address, so a link
830 * straight to a settings page opens the sidebar already drilled in, and
831 * leaving it comes back out. Lists deeper than the one shown stay in
832 * place, off to the right, so going back slides them away rather than
833 * dropping them. Moving to a list at the same depth (a workspace's settings
834 * to a project) fades the new one in where it is.
835 */
836function Drill({ trail }: { trail: Level[] }) {
837 const depth = trail.length - 1;
838 const hydrated = useHydrated();
839 const stack = useRef<Level[]>(trail);
840 const before = stack.current;
841 const within = trail.every(
842 (level, index) => before[index]?.key === level.key,
843 );
844 stack.current = within ? [...trail, ...before.slice(trail.length)] : trail;
845 const levels = stack.current;
846 // What was showing last time, to tell drilling in from moving across.
847 const shown = useRef({ depth, keys: levels.map((level) => level.key) });
848 useEffect(() => {
849 shown.current = { depth, keys: levels.map((level) => level.key) };
850 });
851 return (
852 <div className="relative min-h-0 grow overflow-hidden">
853 {levels.map((level, index) => {
854 const current = index === depth;
855 const place =
856 index < depth
857 ? "pointer-events-none -translate-x-1/4 opacity-0"
858 : current
859 ? "translate-x-0 opacity-100"
860 : "pointer-events-none translate-x-full opacity-0";
861 // A list that was not there before arrives: from the right when
862 // drilling in, in place when moving across.
863 const arriving =
864 hydrated && current && !shown.current.keys.includes(level.key);
865 const enter = arriving
866 ? depth > shown.current.depth
867 ? "starting:translate-x-full starting:opacity-0"
868 : "starting:opacity-0"
869 : "";
870 return (
871 <div
872 key={level.key}
873 className={`${LAYER} ${place} ${enter}`}
874 inert={!current}
875 aria-hidden={!current || undefined}
876 >
877 {level.node}
878 </div>
879 );
880 })}
881 </div>
882 );
883}
884
885/**
886 * The row at the top of a list drilled into: the list's name, which goes
887 * back out, and what it belongs to, faint at the end.
888 */
889function BackRow({
890 to,
891 label,
892 context,
893}: {
894 to: string;
895 label: ReactNode;
896 context?: ReactNode;
897}) {
898 return (
899 <NavRows className="mt-1">
900 <SidebarMenuItem>
901 <SidebarMenuButton asChild className="gap-1.5 pl-1 font-medium text-fg">
902 <Link to={to} prefetch="intent">
903 <ChevronLeft className="text-faint transition-transform group-hover/menu-button:-translate-x-0.5 group-hover/menu-button:text-muted" />
904 <span className="min-w-0 shrink-0 truncate">{label}</span>
905 {context && (
906 <span className="ml-auto min-w-0 truncate pl-2 font-mono text-[0.6875rem] font-normal text-faint">
907 {context}
908 </span>
909 )}
910 </Link>
911 </SidebarMenuButton>
912 </SidebarMenuItem>
913 </NavRows>
914 );
915}
916
917/** A thin rule between groups of a list. */
918function Rule() {
919 return <SidebarSeparator className="my-1.5" />;
920}
921
922/** A quiet heading inside a group: Pinned, Recent. */
923function SidebarSubhead({ children }: { children: ReactNode }) {
924 return (
925 <SidebarGroupLabel asChild className="h-6 pt-0.5">
926 <h3>{children}</h3>
927 </SidebarGroupLabel>
928 );
929}
930
931/**
932 * The workspace's projects as the sidebar keeps them, however many there
933 * are: the person's pins in their order, what they opened last, and the
934 * way to all of them. Pins move by dragging, or with Alt and the arrow
935 * keys; the projects service keeps the order.
936 */
937function SidebarProjects({
938 slug,
939 shell,
940 current,
941}: {
942 slug: string;
943 shell: ShellData;
944 current: boolean;
945}) {
946 const reorder = useFetcher({ key: `pins:${slug}` });
947 const saved = shell.pinned ?? [];
948 // While a new order is on its way, it shows as made.
949 const asked =
950 reorder.formData?.get("intent") === "reorder"
951 ? reorder.formData.getAll("slug").map(String)
952 : null;
953 const pinned = asked
954 ? asked
955 .map((name) => saved.find((project) => project.name === name))
956 .filter((project): project is ShortcutProject => project != null)
957 : saved;
958 const active = shell.repo
959 ? {
960 namespace: shell.repo.namespace,
961 name: shell.repo.name,
962 isPrivate: false,
963 }
964 : null;
965 const recent = recentWith(shell.recent ?? [], pinned, active, slug);
966 const [dragging, setDragging] = useState<number | null>(null);
967 const move = (from: number, by: number) => {
968 const next = movedPin(pinned, from, by);
969 if (!next) return;
970 const form = new FormData();
971 form.set("intent", "reorder");
972 for (const project of next) form.append("slug", project.name);
973 reorder.submit(form, { method: "post", action: `/${slug}/-/pins` });
974 };
975 // Each row pins or unpins in place: the pin shows on hover or focus, and
976 // stays shown on a pinned row's hover so it reads as "unpin".
977 const row = (project: ShortcutProject, isPinned: boolean) => (
978 <SidebarLink
979 to={`/${project.namespace}/${project.name}`}
980 icon={project.isPrivate ? <Lock /> : <Box />}
981 drill="hover"
982 bare={isPinned}
983 action={
984 <PinButton
985 workspace={slug}
986 slug={project.name}
987 name={project.title ?? project.name}
988 pinned={isPinned}
989 small
990 className="absolute top-1/2 right-6 -translate-y-1/2 opacity-0 transition-opacity group-hover/menu-item:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100"
991 />
992 }
993 >
994 {project.title ?? project.name}
995 </SidebarLink>
996 );
997 return (
998 <SidebarGroup>
999 {/* The label and its button share a row, so the button stays level with the words at any text size. */}
1000 <div className="flex items-center justify-between gap-2 pr-1">
1001 <SidebarGroupLabel asChild>
1002 <h2>Projects</h2>
1003 </SidebarGroupLabel>
1004 <Hint label="New project">
1005 <SidebarGroupAction asChild className="relative top-auto right-auto">
1006 <Link to="/new" aria-label="New project">
1007 <Plus />
1008 </Link>
1009 </SidebarGroupAction>
1010 </Hint>
1011 </div>
1012 <SidebarGroupContent>
1013 {/* Only there once something is pinned: each row below has its own pin. */}
1014 {pinned.length > 0 && (
1015 <>
1016 <SidebarSubhead>Pinned</SidebarSubhead>
1017 <SidebarMenu aria-label="Pinned projects">
1018 {pinned.map((project, index) => (
1019 <SidebarMenuItem
1020 key={project.name}
1021 draggable={pinned.length > 1}
1022 onDragStart={(event) => {
1023 setDragging(index);
1024 event.dataTransfer.effectAllowed = "move";
1025 }}
1026 onDragOver={(event) => {
1027 if (dragging != null) event.preventDefault();
1028 }}
1029 onDrop={(event) => {
1030 event.preventDefault();
1031 if (dragging != null && dragging !== index)
1032 move(dragging, index - dragging);
1033 setDragging(null);
1034 }}
1035 onDragEnd={() => setDragging(null)}
1036 onKeyDown={(event) => {
1037 if (
1038 !event.altKey ||
1039 (event.key !== "ArrowUp" && event.key !== "ArrowDown")
1040 )
1041 return;
1042 event.preventDefault();
1043 move(index, event.key === "ArrowUp" ? -1 : 1);
1044 }}
1045 className={`group/pin ${dragging === index ? "opacity-50" : ""}`}
1046 >
1047 {row(project, true)}
1048 {pinned.length > 1 && (
1049 <Hint
1050 label="Drag, or Alt and an arrow key, to reorder"
1051 side="right"
1052 >
1053 <span className="absolute top-1/2 -left-2 -translate-y-1/2 cursor-grab text-faint opacity-0 transition-opacity group-hover/pin:opacity-100">
1054 <GripVertical size={12} aria-hidden="true" />
1055 </span>
1056 </Hint>
1057 )}
1058 </SidebarMenuItem>
1059 ))}
1060 </SidebarMenu>
1061 </>
1062 )}
1063 {recent.length > 0 && (
1064 <>
1065 <SidebarSubhead>Recent</SidebarSubhead>
1066 <SidebarMenu aria-label="Recent projects">
1067 {recent.map((project) => (
1068 <Fragment key={`${project.namespace}/${project.name}`}>
1069 {row(project, false)}
1070 </Fragment>
1071 ))}
1072 </SidebarMenu>
1073 </>
1074 )}
1075 <SidebarMenu className="pt-1">
1076 <SidebarLink
1077 to={`/${slug}/-/projects`}
1078 icon={<LayoutGrid />}
1079 count={shell.repos.length}
1080 current={current}
1081 >
1082 All projects
1083 </SidebarLink>
1084 </SidebarMenu>
1085 </SidebarGroupContent>
1086 </SidebarGroup>
1087 );
1088}
1089
1090/** Insights, coming: the workspace's own page says what it will be. */
1091const INSIGHTS = roadmapItem("insights");
1092
1093/**
1094 * What the repository menu needs. Known from the address before the page's
1095 * data arrives, so the menu is right from the first frame; the counts fill
1096 * in once it does.
1097 */
1098type MenuRepo = {
1099 namespace: string;
1100 name: string;
1101 member: boolean;
1102 /** Whether they see its settings; from the repository's page once it loads. */
1103 settings?: boolean;
1104 /** What they may do there, once the repository's page loads. */
1105 can?: Abilities;
1106 /** The remote it mirrors, once the repository's page loads: a mirror lists only what it offers. */
1107 mirror?: RepoMirror | null;
1108 issues?: number;
1109 pulls?: number;
1110};
1111
1112function sameRepo(
1113 a: { namespace: string; name: string } | null,
1114 b: { namespace: string; name: string } | null,
1115) {
1116 return (
1117 a != null &&
1118 b != null &&
1119 a.namespace.toLowerCase() === b.namespace.toLowerCase() &&
1120 a.name.toLowerCase() === b.name.toLowerCase()
1121 );
1122}
1123
1124/**
1125 * A project's own list, drilled into while you are in it: everything about
1126 * the project, running and its code, and nothing else, with the way back
1127 * out to all of them.
1128 */
1129function RepoMenu({
1130 repo,
1131 isPrivate,
1132 back,
1133}: {
1134 repo: MenuRepo;
1135 isPrivate: boolean;
1136 /** Where the way back leads: the workspace's projects, mission control, or Explore. */
1137 back: { to: string; label: string };
1138}) {
1139 const base = `/${repo.namespace}/${repo.name}`;
1140 // What a member sees, what everyone who can see the project does, and
1141 // what a mirror offers now (lib/chrome.ts).
1142 const shows = new Set(projectPages(repo.member, repo.can, repo.mirror));
1143 const { current: atOverview, pending: toOverview } = useRowState(base, {
1144 end: true,
1145 });
1146 return (
1147 <nav aria-label={`${repo.namespace}/${repo.name}`} className={PANEL}>
1148 <BackRow to={back.to} label={back.label} />
1149 <NavRows className="mt-1">
1150 <SidebarMenuItem>
1151 <Hint label="Overview" side="right">
1152 <SidebarMenuButton
1153 asChild
1154 size="lg"
1155 isActive={atOverview}
1156 isPending={toOverview}
1157 className="gap-2 font-normal"
1158 >
1159 <Link
1160 to={base}
1161 prefetch="intent"
1162 aria-current={atOverview ? "page" : undefined}
1163 >
1164 <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line [&>svg]:size-3.5">
1165 {isPrivate ? <Lock /> : <Box />}
1166 </span>
1167 <span className="min-w-0 truncate font-mono text-[0.8125rem]">
1168 <span className="text-faint">{repo.namespace}/</span>
1169 <span className="font-semibold text-fg">{repo.name}</span>
1170 </span>
1171 </Link>
1172 </SidebarMenuButton>
1173 </Hint>
1174 </SidebarMenuItem>
1175 </NavRows>
1176 {/* A project's pages, in the order people use them. A page with more
1177 than one view shows them as tabs across its top. */}
1178 <Rule />
1179 <NavRows>
1180 <SidebarLink
1181 to={`${base}/code`}
1182 also={[
1183 `${base}/tree`,
1184 `${base}/blob`,
1185 `${base}/commits`,
1186 `${base}/commit`,
1187 `${base}/branches`,
1188 `${base}/tags`,
1189 `${base}/compare`,
1190 ...soonPaths(base, "Code"),
1191 ]}
1192 icon={<Code2 size={15} />}
1193 >
1194 Code
1195 </SidebarLink>
1196 {/* A mirror that is not taken over lists none of these: its work happens on the remote. */}
1197 {shows.has("issues") && (
1198 <SidebarLink
1199 to={`${base}/issues`}
1200 also={[
1201 `${base}/plans`,
1202 `${base}/milestones`,
1203 `${base}/labels`,
1204 ...soonPaths(base, "Issues"),
1205 ]}
1206 icon={<CircleDot size={15} />}
1207 count={repo.issues}
1208 >
1209 Issues
1210 </SidebarLink>
1211 )}
1212 {shows.has("pulls") && (
1213 <SidebarLink
1214 to={`${base}/pulls`}
1215 also={[`${base}/pull`, `${base}/queue`]}
1216 icon={<GitPullRequest size={15} />}
1217 count={repo.pulls}
1218 >
1219 Pull requests
1220 </SidebarLink>
1221 )}
1222 {shows.has("agents") && (
1223 <SidebarLink
1224 to={`${base}/agents`}
1225 also={[
1226 `${base}/sessions`,
1227 `${base}/memory`,
1228 ...soonPaths(base, "Agents"),
1229 ]}
1230 icon={<Bot size={15} />}
1231 >
1232 Agents
1233 </SidebarLink>
1234 )}
1235 {shows.has("actions") && (
1236 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
1237 Workflows
1238 </SidebarLink>
1239 )}
1240 </NavRows>
1241 <Rule />
1242 <NavRows>
1243 {shows.has("deployments") ? (
1244 <SidebarLink
1245 to={`${base}/deployments`}
1246 also={soonPaths(base, "Deployments")}
1247 icon={<Rocket size={15} />}
1248 >
1249 Deployments
1250 </SidebarLink>
1251 ) : null}
1252 {shows.has("observability") && (
1253 <SidebarSoonLink
1254 to={`${base}/soon/logs`}
1255 also={soonPaths(base, "Observability")}
1256 icon={<Activity size={15} />}
1257 about="Logs, errors, uptime and analytics of the project's deployed apps."
1258 >
1259 Observability
1260 </SidebarSoonLink>
1261 )}
1262 {shows.has("security") ? (
1263 <SidebarLink
1264 to={`${base}/security`}
1265 also={soonPaths(base, "Security")}
1266 icon={<ShieldCheck size={15} />}
1267 >
1268 Security
1269 </SidebarLink>
1270 ) : null}
1271 <SidebarLink
1272 to={`${base}/contributors`}
1273 also={[
1274 `${base}/activity`,
1275 `${base}/stargazers`,
1276 ...soonPaths(base, "Insights"),
1277 ]}
1278 icon={<BarChart3 size={15} />}
1279 >
1280 Insights
1281 </SidebarLink>
1282 </NavRows>
1283 {(repo.settings ?? shows.has("settings")) && (
1284 <>
1285 <Rule />
1286 <NavRows>
1287 <SidebarLink
1288 to={`${base}/settings`}
1289 icon={<Settings size={15} />}
1290 drill
1291 >
1292 Settings
1293 </SidebarLink>
1294 </NavRows>
1295 </>
1296 )}
1297 </nav>
1298 );
1299}
1300
1301/**
1302 * A project's settings, drilled into from its list: what is about running
1303 * it, then its agents, then its code and who can reach it. The way back
1304 * leads to the project.
1305 */
1306function RepoSettingsMenu({ repo }: { repo: MenuRepo }) {
1307 const base = `/${repo.namespace}/${repo.name}`;
1308 // Each page shows to the roles that can use it, once the role is known.
1309 const shows = (page: string) =>
1310 !repo.can || repo.can[SETTINGS_CAPABILITY[page] ?? "administer"];
1311 const running = shows("deployments");
1312 const agents = shows("agents") || shows("guardrails");
1313 return (
1314 <nav
1315 aria-label={`${repo.namespace}/${repo.name} settings`}
1316 className={PANEL}
1317 >
1318 <BackRow
1319 to={base}
1320 label="Settings"
1321 context={`${repo.namespace}/${repo.name}`}
1322 />
1323 {shows("") && (
1324 <NavRows className="mt-2">
1325 <SidebarLink
1326 to={`${base}/settings`}
1327 end
1328 icon={<Settings size={15} />}
1329 >
1330 General
1331 </SidebarLink>
1332 </NavRows>
1333 )}
1334 {running && (
1335 <>
1336 {shows("") && <Rule />}
1337 <NavRows className={shows("") ? undefined : "mt-2"}>
1338 <SidebarLink
1339 to={`${base}/settings/deployments`}
1340 icon={<Rocket size={15} />}
1341 >
1342 Deployments
1343 </SidebarLink>
1344 <SidebarLink
1345 to={`${base}/settings/domains`}
1346 icon={<Globe size={15} />}
1347 >
1348 Domains
1349 </SidebarLink>
1350 </NavRows>
1351 </>
1352 )}
1353 {agents && (
1354 <>
1355 <Rule />
1356 <NavRows>
1357 {shows("agents") && (
1358 <SidebarLink
1359 to={`${base}/settings/agents`}
1360 icon={<Bot size={15} />}
1361 >
1362 Agents
1363 </SidebarLink>
1364 )}
1365 {shows("guardrails") && (
1366 <SidebarLink
1367 to={`${base}/settings/guardrails`}
1368 icon={<ShieldCheck size={15} />}
1369 >
1370 Guardrails
1371 </SidebarLink>
1372 )}
1373 </NavRows>
1374 </>
1375 )}
1376 <Rule />
1377 <NavRows>
1378 {shows("repository") && (
1379 <SidebarLink
1380 to={`${base}/settings/repository`}
1381 icon={<BookMarked size={15} />}
1382 >
1383 Repository
1384 </SidebarLink>
1385 )}
1386 {shows("mirroring") && (
1387 <SidebarLink
1388 to={`${base}/settings/mirroring`}
1389 icon={<ArrowLeftRight size={15} />}
1390 >
1391 Mirroring
1392 </SidebarLink>
1393 )}
1394 {shows("access") && (
1395 <SidebarLink
1396 to={`${base}/settings/access`}
1397 icon={<Users size={15} />}
1398 >
1399 Access
1400 </SidebarLink>
1401 )}
1402 {shows("keys") && (
1403 <SidebarLink
1404 to={`${base}/settings/keys`}
1405 icon={<KeyRound size={15} />}
1406 >
1407 Deploy keys
1408 </SidebarLink>
1409 )}
1410 {shows("branches") && (
1411 <SidebarLink
1412 to={`${base}/settings/branches`}
1413 icon={<GitBranch size={15} />}
1414 >
1415 Branches and merging
1416 </SidebarLink>
1417 )}
1418 {shows("rules") && (
1419 <SidebarLink to={`${base}/settings/rules`} icon={<Scale size={15} />}>
1420 Rules
1421 </SidebarLink>
1422 )}
1423 {shows("secrets") && (
1424 <SidebarLink
1425 to={`${base}/settings/secrets`}
1426 icon={<Lock size={15} />}
1427 >
1428 Secrets and variables
1429 </SidebarLink>
1430 )}
1431 {shows("actions") && (
1432 <SidebarLink
1433 to={`${base}/settings/actions`}
1434 icon={<PlayCircle size={15} />}
1435 >
1436 Actions
1437 </SidebarLink>
1438 )}
1439 {shows("environments") && (
1440 <SidebarLink
1441 to={`${base}/settings/environments`}
1442 icon={<Layers size={15} />}
1443 >
1444 Environments
1445 </SidebarLink>
1446 )}
1447 {shows("runners") && (
1448 <SidebarLink
1449 to={`${base}/settings/runners`}
1450 icon={<ServerCog size={15} />}
1451 >
1452 Runners
1453 </SidebarLink>
1454 )}
1455 {shows("webhooks") && (
1456 <SidebarLink
1457 to={`${base}/settings/webhooks`}
1458 icon={<Webhook size={15} />}
1459 >
1460 Webhooks
1461 </SidebarLink>
1462 )}
1463 </NavRows>
1464 </nav>
1465 );
1466}
1467
1468/**
1469 * Whether the menus list Settings → Invites (invites to g1t): always while
1470 * sign-up takes an invite; once anyone can sign up, only for someone with
1471 * invites already made to look back on, which the account menu's data says.
1472 */
1473function useInvitesListed(): boolean {
1474 const inviteOnly = useInviteOnly();
1475 const details = useFetcher<AccountMenuData | null>({ key: "account-menu" });
1476 useEffect(() => {
1477 if (!inviteOnly && details.state === "idle" && details.data === undefined)
1478 details.load("/settings/menu.json");
1479 // eslint-disable-next-line react-hooks/exhaustive-deps
1480 }, [inviteOnly]);
1481 return inviteOnly || details.data?.invites_page === true;
1482}
1483
1484/** Your own settings, drilled into from Your settings: one page each. */
1485function AccountSettingsMenu({ username }: { username: string }) {
1486 const invites = useInvitesListed();
1487 const link = (page: AccountSettingsPage, icon: ReactNode) => (
1488 <SidebarLink to={`/settings/${page}`} icon={icon}>
1489 {ACCOUNT_SETTINGS[page].title}
1490 </SidebarLink>
1491 );
1492 return (
1493 <nav aria-label="Your settings" className={PANEL}>
1494 <BackRow to="/" label="Your settings" context={username} />
1495 <NavRows className="mt-2">
1496 {link("profile", <CircleUserRound size={15} />)}
1497 {link("emails", <Mail size={15} />)}
1498 {invites && link("invites", <Ticket size={15} />)}
1499 </NavRows>
1500 <Rule />
1501 <NavRows>
1502 {link("keys", <Fingerprint size={15} />)}
1503 {link("tokens", <KeyRound size={15} />)}
1504 </NavRows>
1505 <Rule />
1506 <NavRows>
1507 {link("integrations", <Blocks size={15} />)}
1508 {link("github", <GithubMark className="size-3.75" />)}
1509 {link("applications", <Plug size={15} />)}
1510 </NavRows>
1511 <Rule />
1512 <NavRows>
1513 {link("two-factor", <ShieldCheck size={15} />)}
1514 {link("security-log", <History size={15} />)}
1515 </NavRows>
1516 </nav>
1517 );
1518}
1519
1520/**
1521 * Code's sidebar: its Overview, the projects, what spans them and what is
1522 * coming; in a project, drilled into the project's own list, and its
1523 * settings one level further in. The address says which, so a link
1524 * straight to a project opens the sidebar already drilled in.
1525 */
1526function CodeSidebar({
1527 user,
1528 shell,
1529 missing = false,
1530}: {
1531 user: User;
1532 shell: ShellData;
1533 /** The page is a 404: the address names nothing the viewer can see. */
1534 missing?: boolean;
1535}) {
1536 const ws = shell.workspace;
1537 const { pathname } = useLocation();
1538 const going = useNavigation().location?.pathname;
1539 // Where the sidebar is drilled to follows the page being gone to, so it
1540 // moves as the link is followed, not once the page arrives.
1541 const target = going ?? pathname;
1542 // On a 404, the address is not a place: no list is drilled into from it,
1543 // except the project's own when the project is real and visible and only
1544 // something inside it is missing (a file, a commit, an issue).
1545 const lost = missing && going == null;
1546 const active = shell.repo;
1547 // In a repository, or on the way into one, its own list.
1548 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
1549 const reserved = new Set([
1550 "settings",
1551 "explore",
1552 "search",
1553 "new",
1554 "u",
1555 "pricing",
1556 "avatars",
1557 "workspaces",
1558 "login",
1559 "logout",
1560 "register",
1561 "verify",
1562 "forgot",
1563 "reset",
1564 "device",
1565 "oauth",
1566 "policies",
1567 "security",
1568 "support",
1569 "status",
1570 "invite",
1571 "notifications",
1572 "inbox",
1573 ".well-known",
1574 ]);
1575 // An invitation to a repository is answered before its menu means anything.
1576 const inRepo =
1577 repoPath != null &&
1578 !reserved.has(repoPath[1]) &&
1579 repoPath[2] !== "-" &&
1580 !/^\/[^/]+\/[^/]+\/invitations\/?$/.test(target) &&
1581 (!lost ||
1582 sameRepo(active, { namespace: repoPath[1]!, name: repoPath[2]! }));
1583 // A project's settings, one level further in.
1584 const inRepoSettings = inRepo && REPO_SETTINGS_PAGE.test(target);
1585 // The repository the menu is for: the one loaded if it is the one being
1586 // gone to, else what the address says, at once.
1587 const targetRepo =
1588 inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null;
1589 const guessed: MenuRepo | null = targetRepo
1590 ? sameRepo(active, targetRepo)
1591 ? active
1592 : {
1593 ...targetRepo,
1594 member: (user.workspaces ?? []).some(
1595 (m) => m.slug === targetRepo.namespace.toLowerCase(),
1596 ),
1597 }
1598 : null;
1599 // The viewer's role there, from the repository's page once it has loaded.
1600 const page = useRouteLoaderData("routes/repo/layout") as
1601 | {
1602 repo?: { namespace: string; name: string; mirror?: RepoMirror | null };
1603 access?: ViewerAccess;
1604 }
1605 | undefined;
1606 const menuRepo: MenuRepo | null =
1607 guessed && page?.access && page.repo && sameRepo(page.repo, guessed)
1608 ? {
1609 ...guessed,
1610 member: page.access.insider,
1611 settings: seesSettings(page.access),
1612 can: page.access.can,
1613 mirror: page.repo.mirror,
1614 }
1615 : guessed;
1616
1617 // The way from Code's list to the one shown.
1618 const trail: Level[] = [
1619 {
1620 key: "main",
1621 node: ws ? <CodeMenu shell={shell} slug={ws.slug} /> : null,
1622 },
1623 ];
1624 if (menuRepo) {
1625 const key = `repo:${menuRepo.namespace}/${menuRepo.name}`.toLowerCase();
1626 // Out of a project: to its workspace's projects when they are yours.
1627 const home = (user.workspaces ?? []).find(
1628 (m) => m.slug === menuRepo.namespace.toLowerCase(),
1629 );
1630 const back = home
1631 ? { to: `/${home.slug}/-/projects`, label: "All projects" }
1632 : { to: ws ? `/${ws.slug}/-/overview` : "/explore", label: "Code" };
1633 trail.push({
1634 key,
1635 node: (
1636 <RepoMenu
1637 repo={menuRepo}
1638 isPrivate={[...shell.repos, ...(shell.shared ?? [])].some(
1639 (repo) => sameRepo(repo, menuRepo) && repo.isPrivate,
1640 )}
1641 back={back}
1642 />
1643 ),
1644 });
1645 if (inRepoSettings)
1646 trail.push({
1647 key: `${key}:settings`,
1648 node: <RepoSettingsMenu repo={menuRepo} />,
1649 });
1650 }
1651
1652 return (
1653 <div className="flex h-full flex-col">
1654 <ModeHeader title="Code" />
1655 <Drill trail={trail} />
1656 </div>
1657 );
1658}
1659
1660/**
1661 * A mode's sidebar heading: its name, quiet, under the workspace's
1662 * switcher, with what it makes at the end.
1663 */
1664export function ModeHeader({
1665 title,
1666 action,
1667}: {
1668 title: ReactNode;
1669 action?: ReactNode;
1670}) {
1671 return (
1672 <div className="flex h-8 shrink-0 items-center gap-1 pr-2 pl-3">
1673 <SidebarGroupLabel asChild className="min-w-0 grow truncate px-1">
1674 <h2>{title}</h2>
1675 </SidebarGroupLabel>
1676 {action}
1677 </div>
1678 );
1679}
1680
1681/**
1682 * Code's own list (beside the rail): only code. Its Overview (Mission
1683 * control's code panels), the projects, what spans them, and what is
1684 * coming. The workspace's people, money and settings are elsewhere;
1685 * agents, context and memory are Agents'.
1686 */
1687function CodeMenu({ shell, slug }: { shell: ShellData; slug: string }) {
1688 const { pathname } = useLocation();
1689 const going = useNavigation().location?.pathname;
1690 const path = going ?? pathname;
1691 const at = (page: string) =>
1692 path === `/${slug}/-/${page}` || path.startsWith(`/${slug}/-/${page}/`);
1693 const shared = shell.shared ?? [];
1694 return (
1695 <nav aria-label="Code" className={PANEL}>
1696 <NavRows>
1697 <SidebarLink
1698 to={`/${slug}/-/overview`}
1699 icon={<LayoutDashboard size={15} />}
1700 current={at("overview")}
1701 >
1702 Overview
1703 </SidebarLink>
1704 </NavRows>
1705 <Rule />
1706 <SidebarProjects slug={slug} shell={shell} current={at("projects")} />
1707 <Rule />
1708 <NavRows>
1709 <SidebarLink
1710 to={`/${slug}/-/security`}
1711 icon={<ShieldCheck size={15} />}
1712 current={at("security") && !at("security/settings")}
1713 >
1714 Security
1715 </SidebarLink>
1716 <SidebarLink
1717 to={`/${slug}/-/packages`}
1718 icon={<Package size={15} />}
1719 current={at("packages")}
1720 >
1721 Packages
1722 </SidebarLink>
1723 {INSIGHTS && (
1724 <SidebarSoonLink
1725 to={`/${slug}/-/insights`}
1726 icon={<TrendingUp size={15} />}
1727 about={INSIGHTS.summary}
1728 current={at("insights")}
1729 >
1730 {INSIGHTS.title}
1731 </SidebarSoonLink>
1732 )}
1733 {roadmapIn("Workspace")
1734 .filter(
1735 (item) =>
1736 item.key !== "insights" &&
1737 item.key !== "teams" &&
1738 item.key !== "fleet",
1739 )
1740 .map((item) => (
1741 <SidebarSoonLink
1742 key={item.key}
1743 to={`/${slug}/-/soon/${item.key}`}
1744 icon={WORKSPACE_ICONS[item.key] ?? <Sparkles size={15} />}
1745 about={item.summary}
1746 >
1747 {item.title === "Board" ? "Boards" : item.title}
1748 </SidebarSoonLink>
1749 ))}
1750 </NavRows>
1751 {shared.length > 0 && (
1752 <>
1753 <Rule />
1754 <NavGroup title="Shared with you">
1755 {shared.map((repo) => (
1756 <SidebarLink
1757 key={`${repo.namespace}/${repo.name}`}
1758 to={`/${repo.namespace}/${repo.name}`}
1759 icon={repo.isPrivate ? <Lock size={15} /> : <Box size={15} />}
1760 drill="hover"
1761 >
1762 <span className="font-mono text-faint">{repo.namespace}/</span>
1763 {repo.name}
1764 </SidebarLink>
1765 ))}
1766 </NavGroup>
1767 </>
1768 )}
1769 </nav>
1770 );
1771}
1772
1773/** The icon of each of Workspace mode's pages, by its key (lib/workspace-nav.ts). */
1774const WORKSPACE_PAGE_ICONS: Record<string, ReactNode> = {
1775 settings: <Settings size={15} />,
1776 members: <Users size={15} />,
1777 invitations: <Mail size={15} />,
1778 permissions: <ShieldCheck size={15} />,
1779 tokens: <KeyRound size={15} />,
1780 "personal-access-tokens": <UserRoundKey size={15} />,
1781 spend: <Coins size={15} />,
1782 usage: <BarChart3 size={15} />,
1783 gateway: <Network size={15} />,
1784 billing: <CreditCard size={15} />,
1785 runners: <ServerCog size={15} />,
1786 repositories: <BookMarked size={15} />,
1787 actions: <PlayCircle size={15} />,
1788 secrets: <Lock size={15} />,
1789 webhooks: <Webhook size={15} />,
1790 deployments: <Rocket size={15} />,
1791 projects: <Layers size={15} />,
1792 "agent-settings": <Bot size={15} />,
1793 "chat-settings": <MessagesSquare size={15} />,
1794 emoji: <Smile size={15} />,
1795 "artifact-settings": <Shapes size={15} />,
1796 "security-settings": <Shield size={15} />,
1797 guardrails: <Gauge size={15} />,
1798 rules: <Scale size={15} />,
1799 audit: <History size={15} />,
1800 integrations: <Plug size={15} />,
1801 applications: <Blocks size={15} />,
1802};
1803
1804/**
1805 * A group of Workspace mode's rows that folds: its name is the button, with
1806 * a chevron that turns when it is open. The group holding the current page
1807 * is always open, and its button does nothing. Every group starts open, so
1808 * the server and the browser draw the same list.
1809 */
1810function FoldingGroup({
1811 title,
1812 current,
1813 children,
1814}: {
1815 title: string;
1816 current: boolean;
1817 children: ReactNode;
1818}) {
1819 const [open, setOpen] = useState(true);
1820 return (
1821 <Collapsible open={open || current} onOpenChange={setOpen}>
1822 <SidebarGroup>
1823 <CollapsibleTrigger
1824 disabled={current}
1825 aria-label={`${title}: ${open || current ? "fold" : "unfold"}`}
1826 className="flex h-7 w-full shrink-0 items-center justify-between rounded-md px-2 text-[0.6875rem] font-medium tracking-wide text-faint uppercase hover:text-muted focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-default disabled:text-faint data-[state=open]:[&_svg]:rotate-90"
1827 >
1828 <span>{title}</span>
1829 {!current && (
1830 <ChevronRight
1831 size={12}
1832 aria-hidden="true"
1833 className="text-faint/70"
1834 />
1835 )}
1836 </CollapsibleTrigger>
1837 <CollapsibleContent>
1838 <SidebarGroupContent>
1839 <SidebarMenu>{children}</SidebarMenu>
1840 </SidebarGroupContent>
1841 </CollapsibleContent>
1842 </SidebarGroup>
1843 </Collapsible>
1844 );
1845}
1846
1847/**
1848 * The Workspace mode's sidebar: the workspace's settings area, every page
1849 * one click away. Its overview, then the groups in lib/workspace-nav.ts
1850 * (General, Access, Money, Compute, Code, Agents, Chat, Artifacts,
1851 * Security, Integrations), each folding, the one you are in held open.
1852 * Pages only owners open are left out for members, and a group with none
1853 * left goes with them; a page that is coming is a Soon row, not a link.
1854 */
1855export function WorkspaceSidebar({
1856 slug,
1857 owner,
1858}: {
1859 slug: string;
1860 owner: boolean;
1861}) {
1862 const { pathname } = useLocation();
1863 const going = useNavigation().location?.pathname;
1864 const path = going ?? pathname;
1865 const here = workspaceSettingsPage(path, slug);
1866 const overview =
1867 path === `/${slug}/-/workspace` || path.startsWith(`/${slug}/-/workspace/`);
1868 const shown = (page: WorkspaceSettingsPage) => owner || !page.owner;
1869 return (
1870 <div className="flex h-full flex-col">
1871 <ModeHeader title="Workspace" />
1872 <nav aria-label="Workspace" className={PANEL}>
1873 <NavRows>
1874 <SidebarLink
1875 to={`/${slug}/-/workspace`}
1876 icon={<LayoutGrid size={15} />}
1877 current={overview}
1878 >
1879 Overview
1880 </SidebarLink>
1881 </NavRows>
1882 {WORKSPACE_SETTINGS_GROUPS.filter((group) =>
1883 group.pages.some(shown),
1884 ).map((group) => (
1885 <Fragment key={group.key}>
1886 <Rule />
1887 <FoldingGroup
1888 title={group.title}
1889 current={here?.group === group.key}
1890 >
1891 {group.pages.filter(shown).map((page) =>
1892 page.soon ? (
1893 <SidebarSoon
1894 key={page.key}
1895 icon={
1896 WORKSPACE_PAGE_ICONS[page.key] ?? <Sparkles size={15} />
1897 }
1898 about={page.soon}
1899 >
1900 {page.label}
1901 </SidebarSoon>
1902 ) : (
1903 <SidebarLink
1904 key={page.key}
1905 to={`/${slug}/-/${page.path}`}
1906 icon={
1907 WORKSPACE_PAGE_ICONS[page.key] ?? <Settings size={15} />
1908 }
1909 current={here?.page === page.key}
1910 >
1911 {page.label}
1912 </SidebarLink>
1913 ),
1914 )}
1915 </FoldingGroup>
1916 </Fragment>
1917 ))}
1918 </nav>
1919 </div>
1920 );
1921}
1922
1923/**
1924 * People's sidebar: everyone in the workspace (its people; agents are
1925 * under Agents); its teams, which agents can be on; and the org chart. Who
1926 * belongs, and inviting people, is the workspace's: Workspace mode's
1927 * Access group.
1928 */
1929function PeopleSidebar({ slug }: { slug: string }) {
1930 const { pathname } = useLocation();
1931 const going = useNavigation().location?.pathname;
1932 const path = going ?? pathname;
1933 const at = (page: string) =>
1934 path === `/${slug}/-/${page}` || path.startsWith(`/${slug}/-/${page}/`);
1935 return (
1936 <div className="flex h-full flex-col">
1937 <ModeHeader title="People" />
1938 <nav aria-label="People" className={PANEL}>
1939 <NavRows>
1940 <SidebarLink
1941 to={`/${slug}/-/people`}
1942 icon={<Users size={15} />}
1943 current={at("people")}
1944 >
1945 Everyone
1946 </SidebarLink>
1947 <SidebarLink
1948 to={`/${slug}/-/teams`}
1949 icon={<UsersRound size={15} />}
1950 current={at("teams")}
1951 >
1952 Teams
1953 </SidebarLink>
1954 <SidebarLink
1955 to={`/${slug}/-/org-chart`}
1956 icon={<Network size={15} />}
1957 current={at("org-chart")}
1958 >
1959 Org chart
1960 </SidebarLink>
1961 </NavRows>
1962 </nav>
1963 </div>
1964 );
1965}
1966
1967/**
1968 * Notifications' sidebar: what you have not finished with, what you saved
1969 * and what is done; then why you were told, which narrows any of them.
1970 */
1971function NotificationsSidebar({ counts }: { counts: InboxCounts | null }) {
1972 const { search } = useLocation();
1973 const params = new URLSearchParams(search);
1974 const view = inboxView(params.get("view"));
1975 const reason = inboxReason(params.get("reason"));
1976 const tab = params.get("tab");
1977 const address = (changes: Record<string, string | null>) => {
1978 const merged: Record<string, string | null> = {
1979 tab,
1980 view: view === "inbox" ? null : view,
1981 reason,
1982 ...changes,
1983 };
1984 const next = new URLSearchParams();
1985 for (const [name, value] of Object.entries(merged))
1986 if (value) next.set(name, value);
1987 const query = next.toString();
1988 return query ? `/notifications?${query}` : "/notifications";
1989 };
1990 const views = [
1991 {
1992 view: "inbox",
1993 label: "Everything",
1994 icon: <Bell size={15} />,
1995 count: counts?.unread,
1996 },
1997 { view: "saved", label: "Saved", icon: <Bookmark size={15} /> },
1998 { view: "done", label: "Done", icon: <Check size={15} /> },
1999 ] as const;
2000 return (
2001 <div className="flex h-full flex-col">
2002 <ModeHeader title="Notifications" />
2003 <nav aria-label="Notifications" className={PANEL}>
2004 <NavRows>
2005 {views.map((entry) => (
2006 <SidebarLink
2007 key={entry.view}
2008 to={address({
2009 view: entry.view === "inbox" ? null : entry.view,
2010 before: null,
2011 })}
2012 icon={entry.icon}
2013 count={"count" in entry ? (entry.count ?? undefined) : undefined}
2014 current={view === entry.view}
2015 >
2016 {entry.label}
2017 </SidebarLink>
2018 ))}
2019 </NavRows>
2020 <Rule />
2021 <NavGroup title="Why you were told">
2022 {REASON_FILTERS.map((entry) => (
2023 <SidebarLink
2024 key={entry.reason ?? "any"}
2025 to={address({ reason: entry.reason, before: null })}
2026 icon={<span className="block size-3.75" />}
2027 current={reason === entry.reason}
2028 >
2029 {entry.reason == null ? "Any reason" : entry.label}
2030 </SidebarLink>
2031 ))}
2032 </NavGroup>
2033 </nav>
2034 </div>
2035 );
2036}
2037
2038/** Your own settings, beside the page: under the account menu, not any workspace's. */
2039export function AccountSidebar({ username }: { username: string }) {
2040 return (
2041 <div className="flex h-full flex-col">
2042 <ModeHeader title="Your account" />
2043 <div className="min-h-0 grow">
2044 <AccountSettingsMenu username={username} />
2045 </div>
2046 </div>
2047 );
2048}
2049
2050/** Words for the sections a path can end in. */
2051const SECTIONS: Record<string, string> = {
2052 issues: "Issues",
2053 milestones: "Milestones",
2054 labels: "Labels",
2055 pulls: "Pull requests",
2056 queue: "Merge queue",
2057 commits: "Commits",
2058 plans: "Outcomes",
2059 actions: "Workflows",
2060 soon: "Soon",
2061 deployments: "Deployments",
2062 repository: "Repository",
2063 access: "Access",
2064 invitations: "Invitation",
2065 repositories: "Repositories",
2066 branches: "Branches",
2067 tags: "Tags",
2068 compare: "Compare",
2069 code: "Files",
2070 secrets: "Secrets and variables",
2071 settings: "Settings",
2072 people: "People",
2073 "org-chart": "Org chart",
2074 members: "Members",
2075 permissions: "Permissions",
2076 projects: "Projects",
2077 teams: "Teams",
2078 tokens: "Access tokens",
2079 "personal-access-tokens": "Personal access tokens",
2080 spend: "Spend",
2081 receipts: "Receipts",
2082 usage: "Usage",
2083 gateway: "AI Gateway",
2084 billing: "Billing and plans",
2085 integrations: "Integrations",
2086 webhooks: "Webhooks",
2087 emoji: "Emoji",
2088 domains: "Domains",
2089 guardrails: "Guardrails",
2090 rules: "Rules",
2091 audit: "Audit log",
2092 tree: "Files",
2093 blob: "Files",
2094 agents: "Agents",
2095 context: "Context",
2096 memory: "Memory",
2097 security: "Security",
2098 packages: "Packages",
2099 runners: "Runners",
2100 environments: "Environments",
2101 workflows: "Workflows",
2102 observability: "Observability",
2103 insights: "Insights",
2104 sessions: "Sessions",
2105 chat: "Chat",
2106 artifacts: "Artifacts",
2107 home: "Home",
2108 apps: "Apps",
2109 marketplace: "Marketplace",
2110 "code-access": "Code access",
2111 overview: "Overview",
2112 workspace: "Workspace",
2113};
2114
2115/** Settings pages whose name differs from the section's of the same word. */
2116const SETTINGS_SECTIONS: Record<string, string> = {
2117 branches: "Branches and merging",
2118 actions: "Actions",
2119};
2120
2121/** Where the page is, as a trail of links: workspace / repository / section. */
2122function Breadcrumbs({
2123 pathname,
2124 missing,
2125 repo,
2126 workspace,
2127}: {
2128 pathname: string;
2129 missing?: boolean;
2130 repo?: ShellData["repo"];
2131 /** The workspace you are in: the switcher names it, so the trail starts after it. */
2132 workspace: string | null;
2133}) {
2134 // A 404 names nothing from the address, unless the project is real and
2135 // visible and only something inside it is missing.
2136 const visibleRepo = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(pathname);
2137 if (
2138 missing &&
2139 !(
2140 visibleRepo &&
2141 sameRepo(repo ?? null, {
2142 namespace: visibleRepo[1]!,
2143 name: visibleRepo[2]!,
2144 })
2145 )
2146 ) {
2147 return <Here>Not found</Here>;
2148 }
2149 const parts = pathname.split("/").filter(Boolean);
2150 const reserved = [
2151 "settings",
2152 "explore",
2153 "new",
2154 "search",
2155 "workspaces",
2156 "policies",
2157 "security",
2158 "support",
2159 "status",
2160 "invite",
2161 "notifications",
2162 ];
2163 if (parts.length === 0) return <Here>Home</Here>;
2164 // Your settings: Settings / Emails.
2165 if (parts[0] === "settings") {
2166 const page = accountSettingsPage(pathname);
2167 return (
2168 <Trail
2169 trail={[
2170 { label: "Settings", to: FIRST_SETTINGS_PAGE },
2171 ...(page
2172 ? [{ label: ACCOUNT_SETTINGS[page].title, to: `/settings/${page}` }]
2173 : []),
2174 ]}
2175 />
2176 );
2177 }
2178 if (reserved.includes(parts[0]!)) {
2179 const words: Record<string, string> = {
2180 settings: "Account",
2181 explore: "Explore",
2182 new: "New project",
2183 search: "Search",
2184 workspaces: "New workspace",
2185 policies: "Policies",
2186 security: "Security",
2187 support: "Support",
2188 status: "Status",
2189 invite: "Invite",
2190 notifications: "Notifications",
2191 };
2192 return <Here>{words[parts[0]!]}</Here>;
2193 }
2194 // A person's profile, by their handle.
2195 if (parts[0] === "u" && parts[1]) return <ProfileCrumb username={parts[1]} />;
2196 const [owner, second, third, fourth] = parts;
2197 const ours =
2198 workspace != null && owner!.toLowerCase() === workspace.toLowerCase();
2199 const trail: Crumb[] = ours
2200 ? []
2201 : [{ label: owner!, to: `/${owner}`, mono: true }];
2202 if (second === "-") {
2203 const page = `/${owner}/-/${third}`;
2204 if (third === "settings" && fourth === "chat") {
2205 // Chat settings is a page of its own, not one of General's.
2206 trail.push({ label: "Chat settings", to: `${page}/chat` });
2207 } else if (third) {
2208 // The workspace's settings pages each stand alone: General, Members, Permissions.
2209 trail.push({
2210 label: third === "settings" ? "General" : (SECTIONS[third] ?? third),
2211 to: page,
2212 });
2213 // The Marketplace names its tab (Marketplace / Extensions), Agents its templates, and Members its invitations.
2214 const tabs: Record<string, string> =
2215 third === "marketplace"
2216 ? {
2217 integrations: "Integrations",
2218 extensions: "Extensions",
2219 requests: "Requests",
2220 }
2221 : third === "agents"
2222 ? { templates: "Templates" }
2223 : third === "members"
2224 ? { invitations: "Invitations" }
2225 : {};
2226 const tab = fourth ? tabs[fourth] : undefined;
2227 if (tab) trail.push({ label: tab, to: `${page}/${fourth}` });
2228 }
2229 } else if (second) {
2230 const repo = `/${owner}/${second}`;
2231 trail.push({ label: second, to: repo, mono: true });
2232 if (third === "pull" && fourth)
2233 trail.push({
2234 label: `Pull request #${fourth}`,
2235 short: `#${fourth}`,
2236 to: `${repo}/pull/${fourth}`,
2237 });
2238 else if (third === "issues" && fourth && fourth !== "new")
2239 trail.push({
2240 label: `Issue #${fourth}`,
2241 short: `#${fourth}`,
2242 to: `${repo}/issues/${fourth}`,
2243 });
2244 else if (third === "commit" && fourth)
2245 trail.push({
2246 label: fourth.slice(0, 7),
2247 to: `${repo}/commit/${fourth}`,
2248 mono: true,
2249 });
2250 else if (third && SECTIONS[third]) {
2251 trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
2252 // A settings page names which one: Settings / Secrets and variables.
2253 if (third === "settings" && fourth && SECTIONS[fourth]) {
2254 trail.push({
2255 label: SETTINGS_SECTIONS[fourth] ?? SECTIONS[fourth]!,
2256 to: `${repo}/settings/${fourth}`,
2257 });
2258 }
2259 }
2260 }
2261 if (trail.length === 0) trail.push({ label: "Overview", to: `/${owner}` });
2262 return <Trail trail={trail} />;
2263}
2264
2265/** A profile's place in the top bar: the person's avatar and @handle, from the page once it has loaded. */
2266function ProfileCrumb({ username }: { username: string }) {
2267 const page = useRouteLoaderData("routes/user") as
2268 | {
2269 profile?: {
2270 username: string;
2271 displayUsername?: string | null;
2272 avatar: string | null;
2273 };
2274 }
2275 | undefined;
2276 const profile =
2277 page?.profile?.username.toLowerCase() === username.toLowerCase()
2278 ? page.profile
2279 : null;
2280 return (
2281 <Breadcrumb className="min-w-0">
2282 <BreadcrumbList>
2283 <BreadcrumbItem>
2284 <BreadcrumbPage
2285 asChild
2286 className="flex items-center gap-2 hover:bg-raised"
2287 >
2288 <Link to={`/u/${profile?.username ?? username}`}>
2289 <Avatar
2290 name={profile?.username ?? username}
2291 image={profile?.avatar ?? null}
2292 size={20}
2293 />
2294 <span className="truncate font-mono text-[0.8125rem]">
2295 @{profile ? shownUsername(profile) : username}
2296 </span>
2297 </Link>
2298 </BreadcrumbPage>
2299 </BreadcrumbItem>
2300 </BreadcrumbList>
2301 </Breadcrumb>
2302 );
2303}
2304
2305/** A page with no trail to it, such as Today or Search: its name alone. */
2306function Here({ children }: { children: ReactNode }) {
2307 return (
2308 <Breadcrumb className="min-w-0">
2309 <BreadcrumbList>
2310 <BreadcrumbItem>
2311 <BreadcrumbPage>{children}</BreadcrumbPage>
2312 </BreadcrumbItem>
2313 </BreadcrumbList>
2314 </Breadcrumb>
2315 );
2316}
2317
2318/** One link of the trail; `short` is what a phone shows when it is the page itself. */
2319type Crumb = { label: string; to: string; mono?: boolean; short?: string };
2320
2321/**
2322 * The trail of links. On a phone there is room for one: the page itself,
2323 * with a way back to where it sits; the whole trail from a wider screen up.
2324 */
2325function Trail({ trail }: { trail: Crumb[] }) {
2326 // Up on a phone, unless up is the workspace itself: its pages are the tabs.
2327 const up =
2328 trail.length > 1 && !/^\/[^/]+$/.test(trail[trail.length - 2]!.to)
2329 ? trail[trail.length - 2]!
2330 : null;
2331 return (
2332 <Breadcrumb className="flex min-w-0 items-center">
2333 {up && (
2334 <Button
2335 asChild
2336 variant="ghost"
2337 size="icon-bar"
2338 className="-ml-1 sm:hidden"
2339 >
2340 <Link to={up.to} prefetch="intent" aria-label={`Back to ${up.label}`}>
2341 <ChevronLeft size={16} />
2342 </Link>
2343 </Button>
2344 )}
2345 <BreadcrumbList>
2346 {trail.map((crumb, index) => {
2347 const last = index === trail.length - 1;
2348 const label =
2349 last && crumb.short ? (
2350 <>
2351 <span className="sm:hidden">{crumb.short}</span>
2352 <span className="hidden sm:inline">{crumb.label}</span>
2353 </>
2354 ) : (
2355 crumb.label
2356 );
2357 const mono = crumb.mono ? "font-mono text-[0.8125rem]" : "";
2358 return (
2359 <Fragment key={index}>
2360 {index > 0 && <BreadcrumbSeparator className="max-sm:hidden" />}
2361 <BreadcrumbItem className={last ? "" : "max-sm:hidden"}>
2362 {last ? (
2363 // The page you are on is still a link: to its own top, as the trail always was.
2364 <BreadcrumbPage asChild className={`hover:bg-raised ${mono}`}>
2365 <Link to={crumb.to} prefetch="intent">
2366 {label}
2367 </Link>
2368 </BreadcrumbPage>
2369 ) : (
2370 <BreadcrumbLink asChild className={mono}>
2371 <Link to={crumb.to} prefetch="intent">
2372 {label}
2373 </Link>
2374 </BreadcrumbLink>
2375 )}
2376 </BreadcrumbItem>
2377 </Fragment>
2378 );
2379 })}
2380 </BreadcrumbList>
2381 </Breadcrumb>
2382 );
2383}
2384
2385type Command = PaletteCommand;
2386
2387/** Everything the palette can jump to, from what the sidebar already knows. */
2388function commandsFor(user: User, shell: ShellData): Command[] {
2389 const ws = shell.workspace;
2390 const commands: Command[] = [
2391 ...(ws
2392 ? [
2393 {
2394 label: "Home",
2395 to: homePagePath(ws.slug),
2396 icon: <House size={15} />,
2397 },
2398 {
2399 label: "Chat",
2400 to: `/${ws.slug}/-/chat`,
2401 icon: <MessagesSquare size={15} />,
2402 },
2403 {
2404 label: "Agents",
2405 to: `/${ws.slug}/-/agents`,
2406 icon: <Sparkles size={15} />,
2407 },
2408 ...(hasCodeAccess(ws)
2409 ? [
2410 {
2411 label: "Code overview",
2412 to: `/${ws.slug}/-/overview`,
2413 icon: <Code2 size={15} />,
2414 },
2415 ]
2416 : []),
2417 {
2418 label: "Artifacts",
2419 to: `/${ws.slug}/-/artifacts`,
2420 icon: <Shapes size={15} />,
2421 },
2422 {
2423 label: "Apps",
2424 hint: "Everything installed that you can use",
2425 to: `/${ws.slug}/-/apps`,
2426 icon: <LayoutGrid size={15} />,
2427 },
2428 {
2429 label: "Marketplace",
2430 hint: "Extensions and integrations to add",
2431 to: `/${ws.slug}/-/marketplace`,
2432 icon: <Store size={15} />,
2433 },
2434 {
2435 label: "Agent templates",
2436 hint: "Starting points for a new agent",
2437 to: `/${ws.slug}/-/agents/templates`,
2438 icon: <Bot size={15} />,
2439 },
2440 {
2441 label: "Workspace",
2442 hint: "Billing, policies, settings",
2443 to: `/${ws.slug}/-/workspace`,
2444 icon: <Building2 size={15} />,
2445 },
2446 ]
2447 : []),
2448 {
2449 label: "Explore repositories",
2450 to: "/explore",
2451 icon: <Compass size={15} />,
2452 },
2453 {
2454 label: "Search g1t",
2455 hint: "Repositories, code, issues, people",
2456 to: "/search",
2457 icon: <Search size={15} />,
2458 },
2459 {
2460 label: "Notifications",
2461 hint: "What needs you",
2462 to: "/notifications",
2463 icon: <Bell size={15} />,
2464 },
2465 { label: "New project", to: "/new", icon: <Plus size={15} /> },
2466 {
2467 label: "Your settings",
2468 to: FIRST_SETTINGS_PAGE,
2469 icon: <Settings size={15} />,
2470 },
2471 ...(Object.keys(ACCOUNT_SETTINGS) as AccountSettingsPage[])
2472 .filter((page) => page !== "profile")
2473 .map((page) => ({
2474 label: ACCOUNT_SETTINGS[page].title,
2475 hint: "Your settings",
2476 to: `/settings/${page}`,
2477 icon: <Settings size={15} />,
2478 })),
2479 ...THEME_COMMANDS,
2480 ];
2481 const repo = shell.repo;
2482 if (repo) {
2483 const base = `/${repo.namespace}/${repo.name}`;
2484 const name = `${repo.namespace}/${repo.name}`;
2485 commands.unshift(
2486 {
2487 label: "Overview",
2488 hint: name,
2489 to: base,
2490 icon: <LayoutGrid size={15} />,
2491 },
2492 {
2493 label: "Code",
2494 hint: name,
2495 to: `${base}/code`,
2496 icon: <Code2 size={15} />,
2497 },
2498 {
2499 label: "Issues",
2500 hint: name,
2501 to: `${base}/issues`,
2502 icon: <CircleDot size={15} />,
2503 },
2504 {
2505 label: "New issue",
2506 hint: name,
2507 to: `${base}/issues/new`,
2508 icon: <Plus size={15} />,
2509 },
2510 ...(repo.member
2511 ? [
2512 {
2513 label: `Put an agent on ${repo.name}`,
2514 hint: name,
2515 to: `${base}/issues/new?agent=1`,
2516 icon: <Sparkles size={15} />,
2517 },
2518 ]
2519 : []),
2520 {
2521 label: "Pull requests",
2522 hint: name,
2523 to: `${base}/pulls`,
2524 icon: <GitPullRequest size={15} />,
2525 },
2526 {
2527 label: "Commits",
2528 hint: name,
2529 to: `${base}/commits`,
2530 icon: <History size={15} />,
2531 },
2532 ...(repo.member
2533 ? [
2534 {
2535 label: "Plan work",
2536 hint: name,
2537 to: `${base}/plans`,
2538 icon: <ListTree size={15} />,
2539 },
2540 {
2541 label: "Deployments",
2542 hint: name,
2543 to: `${base}/deployments`,
2544 icon: <Rocket size={15} />,
2545 },
2546 {
2547 label: "Secrets and variables",
2548 hint: name,
2549 to: `${base}/settings/secrets`,
2550 icon: <Lock size={15} />,
2551 },
2552 {
2553 label: "Project settings",
2554 hint: name,
2555 to: `${base}/settings`,
2556 icon: <Settings size={15} />,
2557 },
2558 {
2559 label: "Repository settings",
2560 hint: name,
2561 to: `${base}/settings/repository`,
2562 icon: <Settings size={15} />,
2563 },
2564 ]
2565 : []),
2566 );
2567 }
2568 for (const membership of user.workspaces ?? []) {
2569 commands.push(
2570 {
2571 label: displayName(membership),
2572 hint: `Workspace · ${membership.slug}`,
2573 to: `/${membership.slug}`,
2574 icon: (
2575 <Avatar
2576 name={membership.slug}
2577 image={membership.avatar}
2578 size={15}
2579 square
2580 />
2581 ),
2582 },
2583 {
2584 label: "All projects",
2585 hint: membership.slug,
2586 to: `/${membership.slug}/-/projects`,
2587 icon: <LayoutGrid size={15} />,
2588 },
2589 {
2590 label: "People",
2591 hint: membership.slug,
2592 to: `/${membership.slug}/-/people`,
2593 icon: <Users size={15} />,
2594 },
2595 {
2596 label: "Spend",
2597 hint: `${membership.slug} · Budgets and receipts`,
2598 to: `/${membership.slug}/-/spend`,
2599 icon: <Coins size={15} />,
2600 },
2601 {
2602 label: "Usage",
2603 hint: membership.slug,
2604 to: `/${membership.slug}/-/usage`,
2605 icon: <BarChart3 size={15} />,
2606 },
2607 {
2608 label: "AI Gateway",
2609 hint: `${membership.slug} · Usage`,
2610 to: `/${membership.slug}/-/gateway`,
2611 icon: <Network size={15} />,
2612 },
2613 {
2614 label: "Billing and plans",
2615 hint: `${membership.slug} · Settings`,
2616 to: `/${membership.slug}/-/billing`,
2617 icon: <CreditCard size={15} />,
2618 },
2619 {
2620 label: "Access tokens",
2621 hint: `${membership.slug} · Settings`,
2622 to: `/${membership.slug}/-/tokens`,
2623 icon: <KeyRound size={15} />,
2624 },
2625 {
2626 label: "Integrations",
2627 hint: membership.slug,
2628 to: `/${membership.slug}/-/integrations`,
2629 icon: <Plug size={15} />,
2630 },
2631 {
2632 label: "Members",
2633 hint: `${membership.slug} · Access`,
2634 to: `/${membership.slug}/-/members`,
2635 icon: <Users size={15} />,
2636 },
2637 ...(membership.role === "owner"
2638 ? [
2639 {
2640 label: "Invitations",
2641 hint: `${membership.slug} · Access`,
2642 to: `/${membership.slug}/-/members/invitations`,
2643 icon: <Mail size={15} />,
2644 },
2645 {
2646 label: "Permissions",
2647 hint: `${membership.slug} · Access`,
2648 to: `/${membership.slug}/-/permissions`,
2649 icon: <ShieldCheck size={15} />,
2650 },
2651 ]
2652 : []),
2653 );
2654 }
2655 for (const listed of shell.repos) {
2656 commands.push({
2657 label: listed.title ?? listed.name,
2658 hint: `${listed.namespace} · Project`,
2659 to: `/${listed.namespace}/${listed.name}`,
2660 icon: <Box size={15} />,
2661 });
2662 }
2663 // "Put an agent on …": one per project, after the projects themselves.
2664 for (const listed of shell.repos) {
2665 if (
2666 repo &&
2667 listed.namespace === repo.namespace &&
2668 listed.name === repo.name
2669 )
2670 continue;
2671 commands.push({
2672 label: `Put an agent on ${listed.title ?? listed.name}`,
2673 hint: `${listed.namespace}/${listed.name}`,
2674 to: `/${listed.namespace}/${listed.name}/issues/new?agent=1`,
2675 icon: <Sparkles size={15} />,
2676 });
2677 }
2678 return commands;
2679}
2680
2681/**
2682 * The page while the next one loads: after a moment it dims, under the
2683 * bar below, so it is plainly on its way out rather than frozen, and the
2684 * next page fades up from there. Sending a form does not dim it: the
2685 * form's button says it is working. Spread on the element holding the page.
2686 */
2687export function useLeaving() {
2688 const navigation = useNavigation();
2689 const method = navigation.formMethod?.toUpperCase();
2690 const leaving =
2691 navigation.state === "loading" && (method == null || method === "GET");
2692 return {
2693 "aria-busy": leaving || undefined,
2694 className: `transition-opacity motion-reduce:transition-none ${leaving ? "opacity-60 delay-200 duration-300" : "duration-150"}`,
2695 };
2696}
2697
2698/**
2699 * A bar across the top of the page while the next one loads, as GitHub
2700 * has: it appears at once, creeps towards the end while waiting, then fills
2701 * and fades when the page arrives.
2702 */
2703export function Progress() {
2704 const navigation = useNavigation();
2705 const busy = navigation.state !== "idle";
2706 const [width, setWidth] = useState(0);
2707 const [visible, setVisible] = useState(false);
2708 useEffect(() => {
2709 if (busy) {
2710 setVisible(true);
2711 setWidth(12);
2712 // Slows as it goes, never quite arriving until the page does.
2713 const timer = setInterval(
2714 () => setWidth((now) => now + (90 - now) * 0.12),
2715 200,
2716 );
2717 return () => clearInterval(timer);
2718 }
2719 setWidth((now) => (now > 0 ? 100 : 0));
2720 const fade = setTimeout(() => setVisible(false), 250);
2721 const reset = setTimeout(() => setWidth(0), 550);
2722 return () => {
2723 clearTimeout(fade);
2724 clearTimeout(reset);
2725 };
2726 }, [busy]);
2727 return (
2728 <div
2729 aria-hidden="true"
2730 className={`pointer-events-none fixed inset-x-0 top-0 z-60 h-0.75 transition-opacity duration-300 ${
2731 visible ? "opacity-100" : "opacity-0"
2732 }`}
2733 >
2734 <div
2735 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
2736 style={{ width: `${width}%` }}
2737 />
2738 </div>
2739 );
2740}
2741
2742/**
2743 * The top bar's way to g1t: the direct message with the workspace's
2744 * orchestrator, opened or made. The one tinted control in the bar: the
2745 * lavender pixel 1 bare beside its words, on a wash of the accent.
2746 */
2747function AskG1tButton({ slug }: { slug: string }) {
2748 return (
2749 <Hint label="Ask g1t">
2750 <Button
2751 asChild
2752 variant="soft"
2753 size="bar"
2754 className="gap-2 max-md:ring-0 lg:px-3"
2755 >
2756 <Link to={`/${slug}/-/chat?agent=g1t`} aria-label="Ask g1t">
2757 <MarkGlyph className="h-3.5" />
2758 <span className="max-lg:hidden">Ask g1t</span>
2759 </Link>
2760 </Button>
2761 </Hint>
2762 );
2763}
2764
2765/**
2766 * Which sidebar sits beside the page: each mode's own, or none. Home and
2767 * Apps are pages at full width, and so are g1t's public pages, such as a
2768 * profile, which are no workspace's. Without a workspace, only your own
2769 * settings and Notifications have one.
2770 */
2771function sidebarFor(mode: ModeKey, workspace: boolean): Panel | null {
2772 if (!workspace)
2773 return mode === "account" || mode === "notifications" ? mode : null;
2774 if (mode === "home" || mode === "apps" || mode === "site") return null;
2775 return mode;
2776}
2777
2778/** The sidebars that sit beside the page, one per mode that has one. */
2779type Panel =
2780 | "chat"
2781 | "artifacts"
2782 | "agents"
2783 | "code"
2784 | "notifications"
2785 | "people"
2786 | "workspace"
2787 | "account";
2788
2789/** Each sidebar's name, for the button that opens it on a phone. */
2790const MODE_MENU: Record<Panel, string> = {
2791 chat: "Chat",
2792 artifacts: "Artifacts",
2793 agents: "Agents",
2794 code: "Code",
2795 notifications: "Notifications",
2796 people: "People",
2797 workspace: "Workspace",
2798 account: "Your account",
2799};
2800
2801/** Whether a key press happened in something you type into, where Ctrl B is the editor's. */
2802function typing(target: EventTarget | null): boolean {
2803 if (!(target instanceof HTMLElement)) return false;
2804 return (
2805 target.isContentEditable ||
2806 target.closest(
2807 "input, textarea, select, [contenteditable=''], [contenteditable='true'], [role='textbox']",
2808 ) != null
2809 );
2810}
2811
2812/** Ctrl B (⌘B on a Mac) shows or hides the sidebar, except while typing. */
2813function useSidebarShortcut(toggle: () => void) {
2814 useEffect(() => {
2815 const onKey = (event: KeyboardEvent) => {
2816 if (
2817 event.key.toLowerCase() !== "b" ||
2818 event.altKey ||
2819 event.shiftKey ||
2820 !(event.ctrlKey || event.metaKey)
2821 )
2822 return;
2823 if (event.defaultPrevented || typing(event.target)) return;
2824 event.preventDefault();
2825 toggle();
2826 };
2827 window.addEventListener("keydown", onKey);
2828 return () => window.removeEventListener("keydown", onKey);
2829 }, [toggle]);
2830}
2831
2832/**
2833 * Search or jump to: the command palette. On a computer it looks like the
2834 * field it opens, with its shortcut; on a phone, a magnifier.
2835 */
2836function SearchButton({
2837 onClick,
2838 place,
2839}: {
2840 onClick: () => void;
2841 place: "bar" | "sidebar";
2842}) {
2843 const field = (
2844 <Button
2845 variant="outline"
2846 size="sm"
2847 aria-label="Search or jump to"
2848 onClick={onClick}
2849 className={
2850 place === "sidebar"
2851 ? "w-full justify-start bg-bg font-normal text-faint hover:bg-bg hover:text-muted"
2852 : "mr-1 w-[min(18rem,26vw)] justify-start bg-surface font-normal text-faint hover:bg-surface hover:text-muted max-md:hidden"
2853 }
2854 >
2855 <Search size={15} />
2856 <span className="grow truncate text-left">Search or jump to</span>
2857 <PaletteKey className="px-1.5 text-[0.625rem] leading-4 text-muted" />
2858 </Button>
2859 );
2860 if (place === "sidebar") return field;
2861 return (
2862 <>
2863 <Button
2864 variant="ghost"
2865 size="icon-bar"
2866 aria-label="Search or jump to"
2867 onClick={onClick}
2868 className="md:hidden"
2869 >
2870 <Search size={18} />
2871 </Button>
2872 {field}
2873 </>
2874 );
2875}
2876
2877/** The sidebar's shortcut, for the hint on its toggle. */
2878function useSidebarKey(): string {
2879 return sidebarKeyLabel(
2880 typeof navigator === "undefined" ? null : navigator.platform,
2881 );
2882}
2883
2884/**
2885 * The app: three surfaces that never blur together. The rail, a narrow
2886 * column of icons down the window's left edge; the mode's sidebar, flat
2887 * against it with a hairline between, which folds away with Ctrl B and is
2888 * a drawer below 1024px; and the page, flat against the sidebar and
2889 * filling the rest of the window, with its top bar across the top and a
2890 * line under it: the sidebar's toggle, where you are, search, Ask g1t and
2891 * Create new. On a phone the rail is a bar along the bottom and the page
2892 * fills the window above it, flat as well. Only for someone signed in; without a workspace yet, there
2893 * is no rail. The rail and the sidebar are drawn from the sidebar kit
2894 * (components/ui/sidebar.tsx): one provider holds whether the sidebar is
2895 * open on a computer and whether the drawer is.
2896 */
2897export function AppShell({
2898 user,
2899 shell,
2900 missing = false,
2901 banner,
2902 sidebarClosed = false,
2903 children,
2904}: {
2905 user: User;
2906 shell: ShellData;
2907 /** The page is a 404 (see CodeSidebar). */
2908 missing?: boolean;
2909 banner?: ReactNode;
2910 /** Whether the person folded the sidebar away, as their cookie says. */
2911 sidebarClosed?: boolean;
2912 children: ReactNode;
2913}) {
2914 const { pathname } = useLocation();
2915 const [drawer, setDrawer] = useState(false);
2916 const [closed, setClosed] = useState(sidebarClosed);
2917 const [palette, setPalette] = useState(false);
2918 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
2919
2920 // A new page closes the drawer.
2921 useEffect(() => setDrawer(false), [pathname]);
2922 usePaletteShortcut(() => setPalette((open) => !open));
2923 const leaving = useLeaving();
2924 useVisualViewport();
2925 const going = useNavigation().location?.pathname;
2926 const ws = shell.workspace;
2927 const mode = modeOf(going ?? pathname, ws?.slug ?? null);
2928 const panel = sidebarFor(mode, ws != null);
2929 const code = ws ? hasCodeAccess(ws) : false;
2930 const { sidebar: chatSidebar } = useChatSidebar();
2931 // Notify: the feed socket's live counts (lib/notify-client.ts) once it has them; the page's until then.
2932 const live = useLiveBadges(ws?.slug);
2933 const chatUnread = chatSidebar
2934 ? unreadTotals(chatSidebar.entries)
2935 : live?.chat != null
2936 ? { unread: live.chat, mentions: live.mentions ?? 0 }
2937 : (shell.chat ?? { unread: 0, mentions: 0 });
2938 const notificationsUnread = live?.inbox ?? shell.inbox?.unread ?? 0;
2939 const unread = {
2940 notifications: notificationsUnread,
2941 chat: chatUnread.unread,
2942 mentions: chatUnread.mentions,
2943 };
2944
2945 // Folding the sidebar away on a computer is remembered, so the next page is drawn the same from the server.
2946 const toggleSidebar = useCallback(() => {
2947 if (!window.matchMedia("(min-width: 1024px)").matches) {
2948 setDrawer((open) => !open);
2949 return;
2950 }
2951 setClosed((was) => {
2952 document.cookie = sidebarCookie(
2953 !was,
2954 window.location.protocol === "https:",
2955 );
2956 return !was;
2957 });
2958 }, []);
2959 useSidebarShortcut(
2960 useCallback(() => {
2961 if (panel) toggleSidebar();
2962 }, [panel, toggleSidebar]),
2963 );
2964
2965 const inline = panel != null && !closed;
2966 const sidebarNode = () => {
2967 if (!panel) return null;
2968 if (!ws)
2969 return panel === "notifications" ? (
2970 <NotificationsSidebar counts={shell.inbox ?? null} />
2971 ) : (
2972 <AccountSidebar username={user.username} />
2973 );
2974 switch (panel) {
2975 case "code":
2976 return <CodeSidebar user={user} shell={shell} missing={missing} />;
2977 case "chat":
2978 return <ChatSidebar slug={ws.slug} />;
2979 case "artifacts":
2980 return <FoliosSidebar slug={ws.slug} />;
2981 case "agents":
2982 return (
2983 <AgentsSidebar
2984 slug={ws.slug}
2985 shellAgents={shell.agents ?? null}
2986 code={code}
2987 owner={ws.role === "owner"}
2988 />
2989 );
2990 case "notifications":
2991 return <NotificationsSidebar counts={shell.inbox ?? null} />;
2992 case "people":
2993 return <PeopleSidebar slug={ws.slug} />;
2994 case "workspace":
2995 return <WorkspaceSidebar slug={ws.slug} owner={ws.role === "owner"} />;
2996 case "account":
2997 return <AccountSidebar username={user.username} />;
2998 }
2999 };
3000 const sidebarKey = useSidebarKey();
3001 // Search or jump to sits at the top of the sidebar, except in Chat and
3002 // Artifacts, whose sidebars search their own lists: there it stays in the bar.
3003 const sidebarSearch =
3004 panel != null && panel !== "chat" && panel !== "artifacts";
3005 // The sidebar's head: the workspace and its switcher, the way to fold the
3006 // sidebar or close the drawer, and search, which opens the palette.
3007 const sidebarTop = (inDrawer: boolean) => (
3008 <SidebarHeader className="gap-1 px-2 pt-0 pb-2">
3009 <div className="flex h-14 shrink-0 items-center gap-1">
3010 {ws ? (
3011 <WorkspaceSwitcher user={user} workspace={ws} />
3012 ) : (
3013 <Link
3014 to="/"
3015 aria-label="g1t"
3016 className="mr-auto flex h-9 items-center rounded-md px-2"
3017 >
3018 <Logo className="text-[1.125rem]" />
3019 </Link>
3020 )}
3021 {inDrawer ? (
3022 <Button
3023 type="button"
3024 aria-label="Close menu"
3025 onClick={() => setDrawer(false)}
3026 variant="ghost"
3027 size="icon-sm"
3028 className="text-faint"
3029 >
3030 <X size={16} />
3031 </Button>
3032 ) : (
3033 <SidebarTrigger
3034 hint={`Hide the sidebar (${sidebarKey})`}
3035 className="text-faint"
3036 />
3037 )}
3038 </div>
3039 {sidebarSearch && (
3040 <SearchButton place="sidebar" onClick={() => setPalette(true)} />
3041 )}
3042 </SidebarHeader>
3043 );
3044
3045 // On a phone (below 768px): the bottom bar, and a conversation full screen.
3046 const conversation = isConversation(going ?? pathname);
3047 // Where the page panel starts: 8px past the rail (56px), or 8px past the sidebar (56 + 240px) when it is open.
3048 // The page starts where the rail (3.5rem) and, open, the sidebar (15rem) end.
3049 const pad = ws
3050 ? inline
3051 ? "md:pl-14 lg:pl-[18.5rem]"
3052 : "md:pl-14"
3053 : inline
3054 ? "lg:pl-[15rem]"
3055 : "";
3056
3057 return (
3058 <SidebarProvider
3059 open={!closed}
3060 onOpenChange={(open) => setClosed(!open)}
3061 openMobile={drawer}
3062 onOpenMobileChange={setDrawer}
3063 toggleSidebar={toggleSidebar}
3064 >
3065 <Progress />
3066 {ws && (
3067 <div className="fixed inset-y-0 left-0 z-40 hidden md:block">
3068 <Rail
3069 workspace={ws}
3070 pins={shell.pins ?? []}
3071 unread={unread}
3072 account={<AccountMenu user={user} />}
3073 />
3074 </div>
3075 )}
3076 {panel && (
3077 <Sidebar
3078 role="complementary"
3079 aria-label={`${MODE_MENU[panel]} sidebar`}
3080 className={`fixed inset-y-0 z-40 max-lg:hidden ${ws ? "left-14" : "left-0"}`}
3081 >
3082 {sidebarTop(false)}
3083 <div className="min-h-0 grow">{sidebarNode()}</div>
3084 </Sidebar>
3085 )}
3086 {/* The drawer is a sheet: a dialog that holds focus, closes on Escape or
3087 a tap outside, and gives focus back to the button that opened it. */}
3088 {panel && (
3089 <SidebarMobile
3090 title={`${MODE_MENU[panel]} menu`}
3091 className="pb-[env(safe-area-inset-bottom)] max-md:[&_nav_a]:min-h-10"
3092 >
3093 {sidebarTop(true)}
3094 <div className="min-h-0 grow">{sidebarNode()}</div>
3095 </SidebarMobile>
3096 )}
3097
3098 <div className={`min-h-dvh pt-(--frame-top) pb-(--tabbar-h) ${pad}`}>
3099 {/* The top bar always has its line, so a page's own header (data-page-head: a conversation's, a project's,
3100 a sticky one under an open artifact) sits under it as its own band. */}
3101 <div className="flex min-h-[calc(100dvh-var(--frame-top)-var(--tabbar-h))] min-w-0 flex-col bg-bg">
3102 <header
3103 className={`sticky top-(--frame-top) z-30 flex h-14 items-center gap-1 border-b border-line bg-bg pr-2 pl-2 sm:pr-3 ${inline ? "lg:pl-3.5" : ""} ${conversation ? "max-md:hidden" : ""}`}
3104 >
3105 {panel && (
3106 <SidebarTrigger
3107 hint={`Show the sidebar (${sidebarKey})`}
3108 aria-label={drawer ? "Hide the sidebar" : "Show the sidebar"}
3109 aria-expanded={drawer}
3110 className={`max-md:size-11 ${inline ? "lg:hidden" : ""}`}
3111 />
3112 )}
3113 {ws && (
3114 <span
3115 className={`flex min-w-0 shrink items-center gap-1.5 ${inline ? "lg:hidden" : ""}`}
3116 >
3117 <WorkspaceSwitcher user={user} workspace={ws} compact />
3118 <span aria-hidden="true" className="text-line-strong">
3119 /
3120 </span>
3121 </span>
3122 )}
3123 <Breadcrumbs
3124 pathname={pathname}
3125 missing={missing}
3126 repo={shell.repo}
3127 workspace={ws?.slug ?? null}
3128 />
3129 {/* Search, then what's yours (spend, Ask g1t), then the bell and Create new: one height, one rhythm. */}
3130 <div className="ml-auto flex shrink-0 items-center gap-1 md:gap-1.5">
3131 {/* With the sidebar open, search is at its top. */}
3132 <span
3133 className={`flex ${inline && sidebarSearch ? "lg:hidden" : ""}`}
3134 >
3135 <SearchButton place="bar" onClick={() => setPalette(true)} />
3136 </span>
3137 {/* Your spend this month, or the workspace's for owners and billing managers; on a phone it is on Spend. */}
3138 {ws && (
3139 <span className="flex max-md:hidden">
3140 <SpendPill
3141 slug={ws.slug}
3142 mayWorkspace={
3143 ws.role === "owner" ||
3144 !!ws.org_roles?.includes("billing_manager")
3145 }
3146 />
3147 </span>
3148 )}
3149 {ws && <AskG1tButton slug={ws.slug} />}
3150 <Separator
3151 orientation="vertical"
3152 className="mx-1 data-[orientation=vertical]:h-5 max-md:hidden"
3153 />
3154 {/* On a phone, Notifications is in the bottom bar. */}
3155 <span className="flex max-md:hidden">
3156 <NotificationsBell
3157 counts={
3158 shell.inbox
3159 ? { ...shell.inbox, unread: notificationsUnread }
3160 : null
3161 }
3162 />
3163 </span>
3164 <CreateMenu shell={shell} />
3165 {/* Without a workspace there is no rail: the account is here. */}
3166 {!ws && <AccountMenu user={user} side="bottom" />}
3167 </div>
3168 </header>
3169 {banner}
3170 <main
3171 id="content"
3172 tabIndex={-1}
3173 {...leaving}
3174 className={`min-w-0 grow outline-none ${leaving.className}`}
3175 >
3176 <InMain.Provider value={true}>{children}</InMain.Provider>
3177 </main>
3178 </div>
3179 </div>
3180 {ws && (
3181 <BottomBar
3182 user={user}
3183 workspace={ws}
3184 pins={shell.pins ?? []}
3185 unread={unread}
3186 onReselect={panel ? () => setDrawer(true) : undefined}
3187 />
3188 )}
3189 <CommandPalette
3190 open={palette}
3191 onOpenChange={setPalette}
3192 commands={commands}
3193 repo={shell.repo ? `${shell.repo.namespace}/${shell.repo.name}` : null}
3194 />
3195 </SidebarProvider>
3196 );
3197}
3198
3199/** Create new: a project, an issue in the one you are in, a workspace or a team. */
3200function CreateMenu({ shell }: { shell: ShellData }) {
3201 return (
3202 <DropdownMenu>
3203 <Hint label="Create new">
3204 <DropdownMenuTrigger asChild>
3205 <Button
3206 variant="ghost"
3207 size="bar"
3208 aria-label="Create new"
3209 className="gap-0.5 px-1.5 text-fg/90"
3210 >
3211 <Plus size={17} className="max-md:size-5" />
3212 <ChevronDown size={12} className="text-faint max-md:hidden" />
3213 </Button>
3214 </DropdownMenuTrigger>
3215 </Hint>
3216 <DropdownMenuContent align="end" className="min-w-52">
3217 {shell.repo && (
3218 <>
3219 <DropdownMenuLabel className="truncate">
3220 In {shell.repo.name}
3221 </DropdownMenuLabel>
3222 <DropdownMenuItem asChild>
3223 <Link
3224 to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}
3225 >
3226 <CircleDot />
3227 New issue
3228 </Link>
3229 </DropdownMenuItem>
3230 {shell.repo.member && (
3231 <DropdownMenuItem asChild>
3232 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
3233 <ListTree />
3234 Plan work
3235 </Link>
3236 </DropdownMenuItem>
3237 )}
3238 <DropdownMenuSeparator />
3239 </>
3240 )}
3241 <DropdownMenuItem asChild>
3242 <Link to="/new">
3243 <Box />
3244 New project
3245 </Link>
3246 </DropdownMenuItem>
3247 <DropdownMenuItem asChild>
3248 <Link to="/new/github">
3249 <GitBranch />
3250 Import from GitHub
3251 </Link>
3252 </DropdownMenuItem>
3253 {/* A team in the workspace you are in, for whoever it lets create one. A new workspace is made from the workspace switcher in the rail. */}
3254 {shell.workspace && <DropdownMenuSeparator />}
3255 {shell.workspace &&
3256 (mayCreateTeams(
3257 shell.workspace.team_creation,
3258 shell.workspace.role,
3259 ) ? (
3260 <DropdownMenuItem asChild>
3261 <Link to={`/${shell.workspace.slug}/-/teams/new`}>
3262 <UsersRound />
3263 New team
3264 </Link>
3265 </DropdownMenuItem>
3266 ) : (
3267 <DropdownMenuItem disabled>
3268 <UsersRound />
3269 New team
3270 <span className="ml-auto pl-3 text-xs text-faint">
3271 Owners only
3272 </span>
3273 </DropdownMenuItem>
3274 ))}
3275 </DropdownMenuContent>
3276 </DropdownMenu>
3277 );
3278}