Skip to content

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

g1t.sh
3,278 linesCodeBlameRaw
1import {
2 Activity,
3 BarChart3,
4 Building2,
5 MessagesSquare,
6 Bell,
7 BookMarked,
8 BookOpen,
9 Bookmark,
10 Blocks,
11 Bot,
12 Box,
13 Check,
14 ChevronDown,
15 ChevronLeft,
16 ChevronRight,
17 CircleDot,
18 GripVertical,
19 CircleUserRound,
20 Code2,
21 Coins,
22 Compass,
23 CreditCard,
24 Fingerprint,
25 GanttChart,
26 Gauge,
27 GitBranch,
28 GitPullRequest,
29 Globe,
30 History,
31 KanbanSquare,
32 Keyboard,
33 KeyRound,
34 Layers,
35 LayoutDashboard,
36 LayoutGrid,
37 LifeBuoy,
38 ListTree,
39 Lock,
40 LogOut,
41 Mail,
42 MonitorDown,
43 Network,
44 Package,
45 PlayCircle,
46 Plug,
47 Plus,
48 Rocket,
49 Search,
50 ServerCog,
51 Settings,
52 Shapes,
53 Shield,
54 ShieldCheck,
55 Scale,
56 Smile,
57 Sparkles,
58 SlidersHorizontal,
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 customize: "Customize",
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 // Customize names its tab (Customize / Plugins), Agents its templates, and Members its invitations.
2212 const tabs: Record<string, string> =
2213 third === "customize"
2214 ? {
2215 skills: "Skills",
2216 connectors: "Connectors",
2217 plugins: "Plugins",
2218 marketplaces: "Marketplaces",
2219 requests: "Requests",
2220 }
2221 : third === "agents"
2222 ? { templates: "Templates" }
2223 : third === "members"
2224 ? { invitations: "Invitations" }
2225 : {};
2226 const tab = fourth ? tabs[fourth] : undefined;
2227 if (tab) trail.push({ label: tab, to: `${page}/${fourth}` });
2228 }
2229 } else if (second) {
2230 const repo = `/${owner}/${second}`;
2231 trail.push({ label: second, to: repo, mono: true });
2232 if (third === "pull" && fourth)
2233 trail.push({
2234 label: `Pull request #${fourth}`,
2235 short: `#${fourth}`,
2236 to: `${repo}/pull/${fourth}`,
2237 });
2238 else if (third === "issues" && fourth && fourth !== "new")
2239 trail.push({
2240 label: `Issue #${fourth}`,
2241 short: `#${fourth}`,
2242 to: `${repo}/issues/${fourth}`,
2243 });
2244 else if (third === "commit" && fourth)
2245 trail.push({
2246 label: fourth.slice(0, 7),
2247 to: `${repo}/commit/${fourth}`,
2248 mono: true,
2249 });
2250 else if (third && SECTIONS[third]) {
2251 trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
2252 // A settings page names which one: Settings / Secrets and variables.
2253 if (third === "settings" && fourth && SECTIONS[fourth]) {
2254 trail.push({
2255 label: SETTINGS_SECTIONS[fourth] ?? SECTIONS[fourth]!,
2256 to: `${repo}/settings/${fourth}`,
2257 });
2258 }
2259 }
2260 }
2261 if (trail.length === 0) trail.push({ label: "Overview", to: `/${owner}` });
2262 return <Trail trail={trail} />;
2263}
2264
2265/** A profile's place in the top bar: the person's avatar and @handle, from the page once it has loaded. */
2266function ProfileCrumb({ username }: { username: string }) {
2267 const page = useRouteLoaderData("routes/user") as
2268 | {
2269 profile?: {
2270 username: string;
2271 displayUsername?: string | null;
2272 avatar: string | null;
2273 };
2274 }
2275 | undefined;
2276 const profile =
2277 page?.profile?.username.toLowerCase() === username.toLowerCase()
2278 ? page.profile
2279 : null;
2280 return (
2281 <Breadcrumb className="min-w-0">
2282 <BreadcrumbList>
2283 <BreadcrumbItem>
2284 <BreadcrumbPage
2285 asChild
2286 className="flex items-center gap-2 hover:bg-raised"
2287 >
2288 <Link to={`/u/${profile?.username ?? username}`}>
2289 <Avatar
2290 name={profile?.username ?? username}
2291 image={profile?.avatar ?? null}
2292 size={20}
2293 />
2294 <span className="truncate font-mono text-[0.8125rem]">
2295 @{profile ? shownUsername(profile) : username}
2296 </span>
2297 </Link>
2298 </BreadcrumbPage>
2299 </BreadcrumbItem>
2300 </BreadcrumbList>
2301 </Breadcrumb>
2302 );
2303}
2304
2305/** A page with no trail to it, such as Today or Search: its name alone. */
2306function Here({ children }: { children: ReactNode }) {
2307 return (
2308 <Breadcrumb className="min-w-0">
2309 <BreadcrumbList>
2310 <BreadcrumbItem>
2311 <BreadcrumbPage>{children}</BreadcrumbPage>
2312 </BreadcrumbItem>
2313 </BreadcrumbList>
2314 </Breadcrumb>
2315 );
2316}
2317
2318/** One link of the trail; `short` is what a phone shows when it is the page itself. */
2319type Crumb = { label: string; to: string; mono?: boolean; short?: string };
2320
2321/**
2322 * The trail of links. On a phone there is room for one: the page itself,
2323 * with a way back to where it sits; the whole trail from a wider screen up.
2324 */
2325function Trail({ trail }: { trail: Crumb[] }) {
2326 // Up on a phone, unless up is the workspace itself: its pages are the tabs.
2327 const up =
2328 trail.length > 1 && !/^\/[^/]+$/.test(trail[trail.length - 2]!.to)
2329 ? trail[trail.length - 2]!
2330 : null;
2331 return (
2332 <Breadcrumb className="flex min-w-0 items-center">
2333 {up && (
2334 <Button
2335 asChild
2336 variant="ghost"
2337 size="icon-bar"
2338 className="-ml-1 sm:hidden"
2339 >
2340 <Link to={up.to} prefetch="intent" aria-label={`Back to ${up.label}`}>
2341 <ChevronLeft size={16} />
2342 </Link>
2343 </Button>
2344 )}
2345 <BreadcrumbList>
2346 {trail.map((crumb, index) => {
2347 const last = index === trail.length - 1;
2348 const label =
2349 last && crumb.short ? (
2350 <>
2351 <span className="sm:hidden">{crumb.short}</span>
2352 <span className="hidden sm:inline">{crumb.label}</span>
2353 </>
2354 ) : (
2355 crumb.label
2356 );
2357 const mono = crumb.mono ? "font-mono text-[0.8125rem]" : "";
2358 return (
2359 <Fragment key={index}>
2360 {index > 0 && <BreadcrumbSeparator className="max-sm:hidden" />}
2361 <BreadcrumbItem className={last ? "" : "max-sm:hidden"}>
2362 {last ? (
2363 // The page you are on is still a link: to its own top, as the trail always was.
2364 <BreadcrumbPage asChild className={`hover:bg-raised ${mono}`}>
2365 <Link to={crumb.to} prefetch="intent">
2366 {label}
2367 </Link>
2368 </BreadcrumbPage>
2369 ) : (
2370 <BreadcrumbLink asChild className={mono}>
2371 <Link to={crumb.to} prefetch="intent">
2372 {label}
2373 </Link>
2374 </BreadcrumbLink>
2375 )}
2376 </BreadcrumbItem>
2377 </Fragment>
2378 );
2379 })}
2380 </BreadcrumbList>
2381 </Breadcrumb>
2382 );
2383}
2384
2385type Command = PaletteCommand;
2386
2387/** Everything the palette can jump to, from what the sidebar already knows. */
2388function commandsFor(user: User, shell: ShellData): Command[] {
2389 const ws = shell.workspace;
2390 const commands: Command[] = [
2391 ...(ws
2392 ? [
2393 {
2394 label: "Home",
2395 to: homePagePath(ws.slug),
2396 icon: <House size={15} />,
2397 },
2398 {
2399 label: "Chat",
2400 to: `/${ws.slug}/-/chat`,
2401 icon: <MessagesSquare size={15} />,
2402 },
2403 {
2404 label: "Agents",
2405 to: `/${ws.slug}/-/agents`,
2406 icon: <Sparkles size={15} />,
2407 },
2408 ...(hasCodeAccess(ws)
2409 ? [
2410 {
2411 label: "Code overview",
2412 to: `/${ws.slug}/-/overview`,
2413 icon: <Code2 size={15} />,
2414 },
2415 ]
2416 : []),
2417 {
2418 label: "Artifacts",
2419 to: `/${ws.slug}/-/artifacts`,
2420 icon: <Shapes size={15} />,
2421 },
2422 {
2423 label: "Apps",
2424 hint: "Everything installed that you can use",
2425 to: `/${ws.slug}/-/apps`,
2426 icon: <LayoutGrid size={15} />,
2427 },
2428 {
2429 label: "Customize",
2430 hint: "Skills, connectors and plugins",
2431 to: `/${ws.slug}/-/customize`,
2432 icon: <SlidersHorizontal size={15} />,
2433 },
2434 {
2435 label: "Agent templates",
2436 hint: "Starting points for a new agent",
2437 to: `/${ws.slug}/-/agents/templates`,
2438 icon: <Bot size={15} />,
2439 },
2440 {
2441 label: "Workspace",
2442 hint: "Billing, policies, settings",
2443 to: `/${ws.slug}/-/workspace`,
2444 icon: <Building2 size={15} />,
2445 },
2446 ]
2447 : []),
2448 {
2449 label: "Explore repositories",
2450 to: "/explore",
2451 icon: <Compass size={15} />,
2452 },
2453 {
2454 label: "Search g1t",
2455 hint: "Repositories, code, issues, people",
2456 to: "/search",
2457 icon: <Search size={15} />,
2458 },
2459 {
2460 label: "Notifications",
2461 hint: "What needs you",
2462 to: "/notifications",
2463 icon: <Bell size={15} />,
2464 },
2465 { label: "New project", to: "/new", icon: <Plus size={15} /> },
2466 {
2467 label: "Your settings",
2468 to: FIRST_SETTINGS_PAGE,
2469 icon: <Settings size={15} />,
2470 },
2471 ...(Object.keys(ACCOUNT_SETTINGS) as AccountSettingsPage[])
2472 .filter((page) => page !== "profile")
2473 .map((page) => ({
2474 label: ACCOUNT_SETTINGS[page].title,
2475 hint: "Your settings",
2476 to: `/settings/${page}`,
2477 icon: <Settings size={15} />,
2478 })),
2479 ...THEME_COMMANDS,
2480 ];
2481 const repo = shell.repo;
2482 if (repo) {
2483 const base = `/${repo.namespace}/${repo.name}`;
2484 const name = `${repo.namespace}/${repo.name}`;
2485 commands.unshift(
2486 {
2487 label: "Overview",
2488 hint: name,
2489 to: base,
2490 icon: <LayoutGrid size={15} />,
2491 },
2492 {
2493 label: "Code",
2494 hint: name,
2495 to: `${base}/code`,
2496 icon: <Code2 size={15} />,
2497 },
2498 {
2499 label: "Issues",
2500 hint: name,
2501 to: `${base}/issues`,
2502 icon: <CircleDot size={15} />,
2503 },
2504 {
2505 label: "New issue",
2506 hint: name,
2507 to: `${base}/issues/new`,
2508 icon: <Plus size={15} />,
2509 },
2510 ...(repo.member
2511 ? [
2512 {
2513 label: `Put an agent on ${repo.name}`,
2514 hint: name,
2515 to: `${base}/issues/new?agent=1`,
2516 icon: <Sparkles size={15} />,
2517 },
2518 ]
2519 : []),
2520 {
2521 label: "Pull requests",
2522 hint: name,
2523 to: `${base}/pulls`,
2524 icon: <GitPullRequest size={15} />,
2525 },
2526 {
2527 label: "Commits",
2528 hint: name,
2529 to: `${base}/commits`,
2530 icon: <History size={15} />,
2531 },
2532 ...(repo.member
2533 ? [
2534 {
2535 label: "Plan work",
2536 hint: name,
2537 to: `${base}/plans`,
2538 icon: <ListTree size={15} />,
2539 },
2540 {
2541 label: "Deployments",
2542 hint: name,
2543 to: `${base}/deployments`,
2544 icon: <Rocket size={15} />,
2545 },
2546 {
2547 label: "Secrets and variables",
2548 hint: name,
2549 to: `${base}/settings/secrets`,
2550 icon: <Lock size={15} />,
2551 },
2552 {
2553 label: "Project settings",
2554 hint: name,
2555 to: `${base}/settings`,
2556 icon: <Settings size={15} />,
2557 },
2558 {
2559 label: "Repository settings",
2560 hint: name,
2561 to: `${base}/settings/repository`,
2562 icon: <Settings size={15} />,
2563 },
2564 ]
2565 : []),
2566 );
2567 }
2568 for (const membership of user.workspaces ?? []) {
2569 commands.push(
2570 {
2571 label: displayName(membership),
2572 hint: `Workspace · ${membership.slug}`,
2573 to: `/${membership.slug}`,
2574 icon: (
2575 <Avatar
2576 name={membership.slug}
2577 image={membership.avatar}
2578 size={15}
2579 square
2580 />
2581 ),
2582 },
2583 {
2584 label: "All projects",
2585 hint: membership.slug,
2586 to: `/${membership.slug}/-/projects`,
2587 icon: <LayoutGrid size={15} />,
2588 },
2589 {
2590 label: "People",
2591 hint: membership.slug,
2592 to: `/${membership.slug}/-/people`,
2593 icon: <Users size={15} />,
2594 },
2595 {
2596 label: "Spend",
2597 hint: `${membership.slug} · Budgets and receipts`,
2598 to: `/${membership.slug}/-/spend`,
2599 icon: <Coins size={15} />,
2600 },
2601 {
2602 label: "Usage",
2603 hint: membership.slug,
2604 to: `/${membership.slug}/-/usage`,
2605 icon: <BarChart3 size={15} />,
2606 },
2607 {
2608 label: "AI Gateway",
2609 hint: `${membership.slug} · Usage`,
2610 to: `/${membership.slug}/-/gateway`,
2611 icon: <Network size={15} />,
2612 },
2613 {
2614 label: "Billing and plans",
2615 hint: `${membership.slug} · Settings`,
2616 to: `/${membership.slug}/-/billing`,
2617 icon: <CreditCard size={15} />,
2618 },
2619 {
2620 label: "Access tokens",
2621 hint: `${membership.slug} · Settings`,
2622 to: `/${membership.slug}/-/tokens`,
2623 icon: <KeyRound size={15} />,
2624 },
2625 {
2626 label: "Integrations",
2627 hint: membership.slug,
2628 to: `/${membership.slug}/-/integrations`,
2629 icon: <Plug size={15} />,
2630 },
2631 {
2632 label: "Members",
2633 hint: `${membership.slug} · Access`,
2634 to: `/${membership.slug}/-/members`,
2635 icon: <Users size={15} />,
2636 },
2637 ...(membership.role === "owner"
2638 ? [
2639 {
2640 label: "Invitations",
2641 hint: `${membership.slug} · Access`,
2642 to: `/${membership.slug}/-/members/invitations`,
2643 icon: <Mail size={15} />,
2644 },
2645 {
2646 label: "Permissions",
2647 hint: `${membership.slug} · Access`,
2648 to: `/${membership.slug}/-/permissions`,
2649 icon: <ShieldCheck size={15} />,
2650 },
2651 ]
2652 : []),
2653 );
2654 }
2655 for (const listed of shell.repos) {
2656 commands.push({
2657 label: listed.title ?? listed.name,
2658 hint: `${listed.namespace} · Project`,
2659 to: `/${listed.namespace}/${listed.name}`,
2660 icon: <Box size={15} />,
2661 });
2662 }
2663 // "Put an agent on …": one per project, after the projects themselves.
2664 for (const listed of shell.repos) {
2665 if (
2666 repo &&
2667 listed.namespace === repo.namespace &&
2668 listed.name === repo.name
2669 )
2670 continue;
2671 commands.push({
2672 label: `Put an agent on ${listed.title ?? listed.name}`,
2673 hint: `${listed.namespace}/${listed.name}`,
2674 to: `/${listed.namespace}/${listed.name}/issues/new?agent=1`,
2675 icon: <Sparkles size={15} />,
2676 });
2677 }
2678 return commands;
2679}
2680
2681/**
2682 * The page while the next one loads: after a moment it dims, under the
2683 * bar below, so it is plainly on its way out rather than frozen, and the
2684 * next page fades up from there. Sending a form does not dim it: the
2685 * form's button says it is working. Spread on the element holding the page.
2686 */
2687export function useLeaving() {
2688 const navigation = useNavigation();
2689 const method = navigation.formMethod?.toUpperCase();
2690 const leaving =
2691 navigation.state === "loading" && (method == null || method === "GET");
2692 return {
2693 "aria-busy": leaving || undefined,
2694 className: `transition-opacity motion-reduce:transition-none ${leaving ? "opacity-60 delay-200 duration-300" : "duration-150"}`,
2695 };
2696}
2697
2698/**
2699 * A bar across the top of the page while the next one loads, as GitHub
2700 * has: it appears at once, creeps towards the end while waiting, then fills
2701 * and fades when the page arrives.
2702 */
2703export function Progress() {
2704 const navigation = useNavigation();
2705 const busy = navigation.state !== "idle";
2706 const [width, setWidth] = useState(0);
2707 const [visible, setVisible] = useState(false);
2708 useEffect(() => {
2709 if (busy) {
2710 setVisible(true);
2711 setWidth(12);
2712 // Slows as it goes, never quite arriving until the page does.
2713 const timer = setInterval(
2714 () => setWidth((now) => now + (90 - now) * 0.12),
2715 200,
2716 );
2717 return () => clearInterval(timer);
2718 }
2719 setWidth((now) => (now > 0 ? 100 : 0));
2720 const fade = setTimeout(() => setVisible(false), 250);
2721 const reset = setTimeout(() => setWidth(0), 550);
2722 return () => {
2723 clearTimeout(fade);
2724 clearTimeout(reset);
2725 };
2726 }, [busy]);
2727 return (
2728 <div
2729 aria-hidden="true"
2730 className={`pointer-events-none fixed inset-x-0 top-0 z-60 h-0.75 transition-opacity duration-300 ${
2731 visible ? "opacity-100" : "opacity-0"
2732 }`}
2733 >
2734 <div
2735 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
2736 style={{ width: `${width}%` }}
2737 />
2738 </div>
2739 );
2740}
2741
2742/**
2743 * The top bar's way to g1t: the direct message with the workspace's
2744 * orchestrator, opened or made. The one tinted control in the bar: the
2745 * lavender pixel 1 bare beside its words, on a wash of the accent.
2746 */
2747function AskG1tButton({ slug }: { slug: string }) {
2748 return (
2749 <Hint label="Ask g1t">
2750 <Button
2751 asChild
2752 variant="soft"
2753 size="bar"
2754 className="gap-2 max-md:ring-0 lg:px-3"
2755 >
2756 <Link to={`/${slug}/-/chat?agent=g1t`} aria-label="Ask g1t">
2757 <MarkGlyph className="h-3.5" />
2758 <span className="max-lg:hidden">Ask g1t</span>
2759 </Link>
2760 </Button>
2761 </Hint>
2762 );
2763}
2764
2765/**
2766 * Which sidebar sits beside the page: each mode's own, or none. Home and
2767 * Apps are pages at full width, and so are g1t's public pages, such as a
2768 * profile, which are no workspace's. Without a workspace, only your own
2769 * settings and Notifications have one.
2770 */
2771function sidebarFor(mode: ModeKey, workspace: boolean): Panel | null {
2772 if (!workspace)
2773 return mode === "account" || mode === "notifications" ? mode : null;
2774 if (mode === "home" || mode === "apps" || mode === "site") return null;
2775 return mode;
2776}
2777
2778/** The sidebars that sit beside the page, one per mode that has one. */
2779type Panel =
2780 | "chat"
2781 | "artifacts"
2782 | "agents"
2783 | "code"
2784 | "notifications"
2785 | "people"
2786 | "workspace"
2787 | "account";
2788
2789/** Each sidebar's name, for the button that opens it on a phone. */
2790const MODE_MENU: Record<Panel, string> = {
2791 chat: "Chat",
2792 artifacts: "Artifacts",
2793 agents: "Agents",
2794 code: "Code",
2795 notifications: "Notifications",
2796 people: "People",
2797 workspace: "Workspace",
2798 account: "Your account",
2799};
2800
2801/** Whether a key press happened in something you type into, where Ctrl B is the editor's. */
2802function typing(target: EventTarget | null): boolean {
2803 if (!(target instanceof HTMLElement)) return false;
2804 return (
2805 target.isContentEditable ||
2806 target.closest(
2807 "input, textarea, select, [contenteditable=''], [contenteditable='true'], [role='textbox']",
2808 ) != null
2809 );
2810}
2811
2812/** Ctrl B (⌘B on a Mac) shows or hides the sidebar, except while typing. */
2813function useSidebarShortcut(toggle: () => void) {
2814 useEffect(() => {
2815 const onKey = (event: KeyboardEvent) => {
2816 if (
2817 event.key.toLowerCase() !== "b" ||
2818 event.altKey ||
2819 event.shiftKey ||
2820 !(event.ctrlKey || event.metaKey)
2821 )
2822 return;
2823 if (event.defaultPrevented || typing(event.target)) return;
2824 event.preventDefault();
2825 toggle();
2826 };
2827 window.addEventListener("keydown", onKey);
2828 return () => window.removeEventListener("keydown", onKey);
2829 }, [toggle]);
2830}
2831
2832/**
2833 * Search or jump to: the command palette. On a computer it looks like the
2834 * field it opens, with its shortcut; on a phone, a magnifier.
2835 */
2836function SearchButton({
2837 onClick,
2838 place,
2839}: {
2840 onClick: () => void;
2841 place: "bar" | "sidebar";
2842}) {
2843 const field = (
2844 <Button
2845 variant="outline"
2846 size="sm"
2847 aria-label="Search or jump to"
2848 onClick={onClick}
2849 className={
2850 place === "sidebar"
2851 ? "w-full justify-start bg-bg font-normal text-faint hover:bg-bg hover:text-muted"
2852 : "mr-1 w-[min(18rem,26vw)] justify-start bg-surface font-normal text-faint hover:bg-surface hover:text-muted max-md:hidden"
2853 }
2854 >
2855 <Search size={15} />
2856 <span className="grow truncate text-left">Search or jump to</span>
2857 <PaletteKey className="px-1.5 text-[0.625rem] leading-4 text-muted" />
2858 </Button>
2859 );
2860 if (place === "sidebar") return field;
2861 return (
2862 <>
2863 <Button
2864 variant="ghost"
2865 size="icon-bar"
2866 aria-label="Search or jump to"
2867 onClick={onClick}
2868 className="md:hidden"
2869 >
2870 <Search size={18} />
2871 </Button>
2872 {field}
2873 </>
2874 );
2875}
2876
2877/** The sidebar's shortcut, for the hint on its toggle. */
2878function useSidebarKey(): string {
2879 return sidebarKeyLabel(
2880 typeof navigator === "undefined" ? null : navigator.platform,
2881 );
2882}
2883
2884/**
2885 * The app: three surfaces that never blur together. The rail, a narrow
2886 * column of icons down the window's left edge; the mode's sidebar, flat
2887 * against it with a hairline between, which folds away with Ctrl B and is
2888 * a drawer below 1024px; and the page, flat against the sidebar and
2889 * filling the rest of the window, with its top bar across the top and a
2890 * line under it: the sidebar's toggle, where you are, search, Ask g1t and
2891 * Create new. On a phone the rail is a bar along the bottom and the page
2892 * fills the window above it, flat as well. Only for someone signed in; without a workspace yet, there
2893 * is no rail. The rail and the sidebar are drawn from the sidebar kit
2894 * (components/ui/sidebar.tsx): one provider holds whether the sidebar is
2895 * open on a computer and whether the drawer is.
2896 */
2897export function AppShell({
2898 user,
2899 shell,
2900 missing = false,
2901 banner,
2902 sidebarClosed = false,
2903 children,
2904}: {
2905 user: User;
2906 shell: ShellData;
2907 /** The page is a 404 (see CodeSidebar). */
2908 missing?: boolean;
2909 banner?: ReactNode;
2910 /** Whether the person folded the sidebar away, as their cookie says. */
2911 sidebarClosed?: boolean;
2912 children: ReactNode;
2913}) {
2914 const { pathname } = useLocation();
2915 const [drawer, setDrawer] = useState(false);
2916 const [closed, setClosed] = useState(sidebarClosed);
2917 const [palette, setPalette] = useState(false);
2918 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
2919
2920 // A new page closes the drawer.
2921 useEffect(() => setDrawer(false), [pathname]);
2922 usePaletteShortcut(() => setPalette((open) => !open));
2923 const leaving = useLeaving();
2924 useVisualViewport();
2925 const going = useNavigation().location?.pathname;
2926 const ws = shell.workspace;
2927 const mode = modeOf(going ?? pathname, ws?.slug ?? null);
2928 const panel = sidebarFor(mode, ws != null);
2929 const code = ws ? hasCodeAccess(ws) : false;
2930 const { sidebar: chatSidebar } = useChatSidebar();
2931 // Notify: the feed socket's live counts (lib/notify-client.ts) once it has them; the page's until then.
2932 const live = useLiveBadges(ws?.slug);
2933 const chatUnread = chatSidebar
2934 ? unreadTotals(chatSidebar.entries)
2935 : live?.chat != null
2936 ? { unread: live.chat, mentions: live.mentions ?? 0 }
2937 : (shell.chat ?? { unread: 0, mentions: 0 });
2938 const notificationsUnread = live?.inbox ?? shell.inbox?.unread ?? 0;
2939 const unread = {
2940 notifications: notificationsUnread,
2941 chat: chatUnread.unread,
2942 mentions: chatUnread.mentions,
2943 };
2944
2945 // Folding the sidebar away on a computer is remembered, so the next page is drawn the same from the server.
2946 const toggleSidebar = useCallback(() => {
2947 if (!window.matchMedia("(min-width: 1024px)").matches) {
2948 setDrawer((open) => !open);
2949 return;
2950 }
2951 setClosed((was) => {
2952 document.cookie = sidebarCookie(
2953 !was,
2954 window.location.protocol === "https:",
2955 );
2956 return !was;
2957 });
2958 }, []);
2959 useSidebarShortcut(
2960 useCallback(() => {
2961 if (panel) toggleSidebar();
2962 }, [panel, toggleSidebar]),
2963 );
2964
2965 const inline = panel != null && !closed;
2966 const sidebarNode = () => {
2967 if (!panel) return null;
2968 if (!ws)
2969 return panel === "notifications" ? (
2970 <NotificationsSidebar counts={shell.inbox ?? null} />
2971 ) : (
2972 <AccountSidebar username={user.username} />
2973 );
2974 switch (panel) {
2975 case "code":
2976 return <CodeSidebar user={user} shell={shell} missing={missing} />;
2977 case "chat":
2978 return <ChatSidebar slug={ws.slug} />;
2979 case "artifacts":
2980 return <FoliosSidebar slug={ws.slug} />;
2981 case "agents":
2982 return (
2983 <AgentsSidebar
2984 slug={ws.slug}
2985 shellAgents={shell.agents ?? null}
2986 code={code}
2987 owner={ws.role === "owner"}
2988 />
2989 );
2990 case "notifications":
2991 return <NotificationsSidebar counts={shell.inbox ?? null} />;
2992 case "people":
2993 return <PeopleSidebar slug={ws.slug} />;
2994 case "workspace":
2995 return <WorkspaceSidebar slug={ws.slug} owner={ws.role === "owner"} />;
2996 case "account":
2997 return <AccountSidebar username={user.username} />;
2998 }
2999 };
3000 const sidebarKey = useSidebarKey();
3001 // Search or jump to sits at the top of the sidebar, except in Chat and
3002 // Artifacts, whose sidebars search their own lists: there it stays in the bar.
3003 const sidebarSearch =
3004 panel != null && panel !== "chat" && panel !== "artifacts";
3005 // The sidebar's head: the workspace and its switcher, the way to fold the
3006 // sidebar or close the drawer, and search, which opens the palette.
3007 const sidebarTop = (inDrawer: boolean) => (
3008 <SidebarHeader className="gap-1 px-2 pt-0 pb-2">
3009 <div className="flex h-14 shrink-0 items-center gap-1">
3010 {ws ? (
3011 <WorkspaceSwitcher user={user} workspace={ws} />
3012 ) : (
3013 <Link
3014 to="/"
3015 aria-label="g1t"
3016 className="mr-auto flex h-9 items-center rounded-md px-2"
3017 >
3018 <Logo className="text-[1.125rem]" />
3019 </Link>
3020 )}
3021 {inDrawer ? (
3022 <Button
3023 type="button"
3024 aria-label="Close menu"
3025 onClick={() => setDrawer(false)}
3026 variant="ghost"
3027 size="icon-sm"
3028 className="text-faint"
3029 >
3030 <X size={16} />
3031 </Button>
3032 ) : (
3033 <SidebarTrigger
3034 hint={`Hide the sidebar (${sidebarKey})`}
3035 className="text-faint"
3036 />
3037 )}
3038 </div>
3039 {sidebarSearch && (
3040 <SearchButton place="sidebar" onClick={() => setPalette(true)} />
3041 )}
3042 </SidebarHeader>
3043 );
3044
3045 // On a phone (below 768px): the bottom bar, and a conversation full screen.
3046 const conversation = isConversation(going ?? pathname);
3047 // Where the page panel starts: 8px past the rail (56px), or 8px past the sidebar (56 + 240px) when it is open.
3048 // The page starts where the rail (3.5rem) and, open, the sidebar (15rem) end.
3049 const pad = ws
3050 ? inline
3051 ? "md:pl-14 lg:pl-[18.5rem]"
3052 : "md:pl-14"
3053 : inline
3054 ? "lg:pl-[15rem]"
3055 : "";
3056
3057 return (
3058 <SidebarProvider
3059 open={!closed}
3060 onOpenChange={(open) => setClosed(!open)}
3061 openMobile={drawer}
3062 onOpenMobileChange={setDrawer}
3063 toggleSidebar={toggleSidebar}
3064 >
3065 <Progress />
3066 {ws && (
3067 <div className="fixed inset-y-0 left-0 z-40 hidden md:block">
3068 <Rail
3069 workspace={ws}
3070 pins={shell.pins ?? []}
3071 unread={unread}
3072 account={<AccountMenu user={user} />}
3073 />
3074 </div>
3075 )}
3076 {panel && (
3077 <Sidebar
3078 role="complementary"
3079 aria-label={`${MODE_MENU[panel]} sidebar`}
3080 className={`fixed inset-y-0 z-40 max-lg:hidden ${ws ? "left-14" : "left-0"}`}
3081 >
3082 {sidebarTop(false)}
3083 <div className="min-h-0 grow">{sidebarNode()}</div>
3084 </Sidebar>
3085 )}
3086 {/* The drawer is a sheet: a dialog that holds focus, closes on Escape or
3087 a tap outside, and gives focus back to the button that opened it. */}
3088 {panel && (
3089 <SidebarMobile
3090 title={`${MODE_MENU[panel]} menu`}
3091 className="pb-[env(safe-area-inset-bottom)] max-md:[&_nav_a]:min-h-10"
3092 >
3093 {sidebarTop(true)}
3094 <div className="min-h-0 grow">{sidebarNode()}</div>
3095 </SidebarMobile>
3096 )}
3097
3098 <div className={`min-h-dvh pt-(--frame-top) pb-(--tabbar-h) ${pad}`}>
3099 {/* The top bar always has its line, so a page's own header (data-page-head: a conversation's, a project's,
3100 a sticky one under an open artifact) sits under it as its own band. */}
3101 <div className="flex min-h-[calc(100dvh-var(--frame-top)-var(--tabbar-h))] min-w-0 flex-col bg-bg">
3102 <header
3103 className={`sticky top-(--frame-top) z-30 flex h-14 items-center gap-1 border-b border-line bg-bg pr-2 pl-2 sm:pr-3 ${inline ? "lg:pl-3.5" : ""} ${conversation ? "max-md:hidden" : ""}`}
3104 >
3105 {panel && (
3106 <SidebarTrigger
3107 hint={`Show the sidebar (${sidebarKey})`}
3108 aria-label={drawer ? "Hide the sidebar" : "Show the sidebar"}
3109 aria-expanded={drawer}
3110 className={`max-md:size-11 ${inline ? "lg:hidden" : ""}`}
3111 />
3112 )}
3113 {ws && (
3114 <span
3115 className={`flex min-w-0 shrink items-center gap-1.5 ${inline ? "lg:hidden" : ""}`}
3116 >
3117 <WorkspaceSwitcher user={user} workspace={ws} compact />
3118 <span aria-hidden="true" className="text-line-strong">
3119 /
3120 </span>
3121 </span>
3122 )}
3123 <Breadcrumbs
3124 pathname={pathname}
3125 missing={missing}
3126 repo={shell.repo}
3127 workspace={ws?.slug ?? null}
3128 />
3129 {/* Search, then what's yours (spend, Ask g1t), then the bell and Create new: one height, one rhythm. */}
3130 <div className="ml-auto flex shrink-0 items-center gap-1 md:gap-1.5">
3131 {/* With the sidebar open, search is at its top. */}
3132 <span
3133 className={`flex ${inline && sidebarSearch ? "lg:hidden" : ""}`}
3134 >
3135 <SearchButton place="bar" onClick={() => setPalette(true)} />
3136 </span>
3137 {/* Your spend this month, or the workspace's for owners and billing managers; on a phone it is on Spend. */}
3138 {ws && (
3139 <span className="flex max-md:hidden">
3140 <SpendPill
3141 slug={ws.slug}
3142 mayWorkspace={
3143 ws.role === "owner" ||
3144 !!ws.org_roles?.includes("billing_manager")
3145 }
3146 />
3147 </span>
3148 )}
3149 {ws && <AskG1tButton slug={ws.slug} />}
3150 <Separator
3151 orientation="vertical"
3152 className="mx-1 data-[orientation=vertical]:h-5 max-md:hidden"
3153 />
3154 {/* On a phone, Notifications is in the bottom bar. */}
3155 <span className="flex max-md:hidden">
3156 <NotificationsBell
3157 counts={
3158 shell.inbox
3159 ? { ...shell.inbox, unread: notificationsUnread }
3160 : null
3161 }
3162 />
3163 </span>
3164 <CreateMenu shell={shell} />
3165 {/* Without a workspace there is no rail: the account is here. */}
3166 {!ws && <AccountMenu user={user} side="bottom" />}
3167 </div>
3168 </header>
3169 {banner}
3170 <main
3171 id="content"
3172 tabIndex={-1}
3173 {...leaving}
3174 className={`min-w-0 grow outline-none ${leaving.className}`}
3175 >
3176 <InMain.Provider value={true}>{children}</InMain.Provider>
3177 </main>
3178 </div>
3179 </div>
3180 {ws && (
3181 <BottomBar
3182 user={user}
3183 workspace={ws}
3184 pins={shell.pins ?? []}
3185 unread={unread}
3186 onReselect={panel ? () => setDrawer(true) : undefined}
3187 />
3188 )}
3189 <CommandPalette
3190 open={palette}
3191 onOpenChange={setPalette}
3192 commands={commands}
3193 repo={shell.repo ? `${shell.repo.namespace}/${shell.repo.name}` : null}
3194 />
3195 </SidebarProvider>
3196 );
3197}
3198
3199/** Create new: a project, an issue in the one you are in, a workspace or a team. */
3200function CreateMenu({ shell }: { shell: ShellData }) {
3201 return (
3202 <DropdownMenu>
3203 <Hint label="Create new">
3204 <DropdownMenuTrigger asChild>
3205 <Button
3206 variant="ghost"
3207 size="bar"
3208 aria-label="Create new"
3209 className="gap-0.5 px-1.5 text-fg/90"
3210 >
3211 <Plus size={17} className="max-md:size-5" />
3212 <ChevronDown size={12} className="text-faint max-md:hidden" />
3213 </Button>
3214 </DropdownMenuTrigger>
3215 </Hint>
3216 <DropdownMenuContent align="end" className="min-w-52">
3217 {shell.repo && (
3218 <>
3219 <DropdownMenuLabel className="truncate">
3220 In {shell.repo.name}
3221 </DropdownMenuLabel>
3222 <DropdownMenuItem asChild>
3223 <Link
3224 to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}
3225 >
3226 <CircleDot />
3227 New issue
3228 </Link>
3229 </DropdownMenuItem>
3230 {shell.repo.member && (
3231 <DropdownMenuItem asChild>
3232 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
3233 <ListTree />
3234 Plan work
3235 </Link>
3236 </DropdownMenuItem>
3237 )}
3238 <DropdownMenuSeparator />
3239 </>
3240 )}
3241 <DropdownMenuItem asChild>
3242 <Link to="/new">
3243 <Box />
3244 New project
3245 </Link>
3246 </DropdownMenuItem>
3247 <DropdownMenuItem asChild>
3248 <Link to="/new/github">
3249 <GitBranch />
3250 Import from GitHub
3251 </Link>
3252 </DropdownMenuItem>
3253 {/* A team in the workspace you are in, for whoever it lets create one. A new workspace is made from the workspace switcher in the rail. */}
3254 {shell.workspace && <DropdownMenuSeparator />}
3255 {shell.workspace &&
3256 (mayCreateTeams(
3257 shell.workspace.team_creation,
3258 shell.workspace.role,
3259 ) ? (
3260 <DropdownMenuItem asChild>
3261 <Link to={`/${shell.workspace.slug}/-/teams/new`}>
3262 <UsersRound />
3263 New team
3264 </Link>
3265 </DropdownMenuItem>
3266 ) : (
3267 <DropdownMenuItem disabled>
3268 <UsersRound />
3269 New team
3270 <span className="ml-auto pl-3 text-xs text-faint">
3271 Owners only
3272 </span>
3273 </DropdownMenuItem>
3274 ))}
3275 </DropdownMenuContent>
3276 </DropdownMenu>
3277 );
3278}