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