Skip to content
2,332 linesCodeBlameRaw
1import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, Inbox, KanbanSquare, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Scale, Smile, Sparkles, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react";
2import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
3import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
4
5import { type Abilities, type ChatSidebarEntry, type InboxCounts, type WorkspaceAgent, type Membership, type Spike, type User, hasCodeAccess, mayCreateTeams } from "@g1t/contracts";
6
7import { InMain } from "./landmark";
8import { CommandPalette, type PaletteCommand, PaletteKey, usePaletteShortcut } from "./command-palette";
9import { AgentButton, InboxBell } from "./inbox";
10import { PinButton } from "./pin-button";
11import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
12import { Hint } from "./ui/hint";
13import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "./ui/sheet";
14import { StatusDot, useSiteStatus } from "./footer";
15import { Logo, Mark } from "./logo";
16import { Avatar, SoonPill } from "./ui";
17import { Skeleton } from "./ui/skeleton";
18import {
19 DropdownMenu,
20 DropdownMenuContent,
21 DropdownMenuGroup,
22 DropdownMenuItem,
23 DropdownMenuLabel,
24 DropdownMenuSeparator,
25 DropdownMenuTrigger,
26} from "./ui/dropdown-menu";
27import { type RoadmapItem, roadmapIn, roadmapItem } from "../lib/roadmap";
28import { type ModeKey, SETTINGS_PAGES, modeOf, sidebarCurrent } from "../lib/workspace-nav";
29import { AgentsSidebar } from "./agents-mode";
30import { ChatSidebar } from "./chat/sidebar";
31import { HelpMenu, Rail } from "./rail";
32import { HomeSidebar } from "./home-sidebar";
33import { G1tMark } from "./orchestrator";
34import { AvatarSheetButton, MobileTabBar, isConversation, useVisualViewport } from "./mobile";
35import { useChatSidebar } from "./chat/actions";
36import { unreadTotals } from "../lib/chat";
37import { SETTINGS_CAPABILITY, type ViewerAccess, seesSettings } from "../lib/access";
38import { VISITOR_LINKS, projectPages } from "../lib/chrome";
39import { ACCOUNT_SETTINGS, type AccountSettingsPage, FIRST_SETTINGS_PAGE, accountSettingsPage } from "../lib/account-settings";
40import { GithubMark } from "./github";
41import { withNext } from "../lib/next";
42import { useInviteOnly, useSignUpCopy } from "../lib/registration";
43import { STATUS_URL, statusTitle } from "../lib/status";
44import { type ShortcutProject, movedPin, recentWith } from "../lib/pins";
45import type { AccountMenuData } from "../routes/settings-menu-json";
46import { useLiveBadges } from "../lib/notify-client";
47
48/**
49 * What the sidebar needs, worked out by the root loader. For a visitor who
50 * is not signed in there is no workspace, no projects and no usage: only
51 * the project being looked at, if they can see it.
52 */
53export type ShellData = {
54 /** The workspace the sidebar is about: the one being looked at, or their first. */
55 workspace: Membership | null;
56 /** All its projects, by name, for the palette: `name` is the slug in their address. */
57 repos: ShortcutProject[];
58 /** The person's pinned projects in it, in their order (lib/pins.ts). */
59 pinned?: ShortcutProject[];
60 /** What they opened there last, latest first, leaving out the pinned. */
61 recent?: ShortcutProject[];
62 /** Repositories shared with them in workspaces they do not belong to. */
63 shared?: { namespace: string; name: string; isPrivate: boolean }[];
64 /** The project being looked at, if any, whoever owns it. */
65 repo: {
66 namespace: string;
67 name: string;
68 member: boolean;
69 issues: number;
70 pulls: number;
71 } | null;
72 /** Where the workspace stands against its usage limit, if billing is on. */
73 limit: {
74 exposureMicros: number;
75 ceilingMicros: number | null;
76 state: "ok" | "warning" | "stopped";
77 comped: boolean;
78 } | null;
79 /** Whether g1t charges nothing for now, while it is being built out. */
80 free?: boolean;
81 /** What its agents have cost since the start of the month. */
82 /** This month's usage: charged, or at cost while g1t is free. */
83 monthUsageMicros: number | null;
84 /** Whether new compute is paused (a spend spike or a hold), and whether the viewer can answer it. */
85 compute?: { paused: string | null; spike: Spike | null; owner: boolean } | null;
86 /** What is unread in their inbox, for the bell. Absent for a visitor. */
87 inbox?: InboxCounts | null;
88 /**
89 * Chat, for the rail's badge and Home's sidebar: what is unread, and the
90 * starred and latest conversations. Null when chat did not answer in time.
91 */
92 chat?: { unread: number; mentions: number; starred?: ChatSidebarEntry[]; recent?: ChatSidebarEntry[] } | null;
93 /** The workspace's agents, g1t first, for Home's and Agents' sidebars; null when not known. */
94 agents?: ShellAgent[] | null;
95};
96
97/** An agent as the shell lists it. */
98export type ShellAgent = Pick<
99 WorkspaceAgent,
100 "id" | "handle" | "display_name" | "avatar" | "avatar_seed" | "role" | "status" | "title" | "team" | "department" | "builtin"
101>;
102
103function SidebarLink({
104 to,
105 icon,
106 end,
107 count,
108 also,
109 drill,
110 current: lit,
111 children,
112}: {
113 to: string;
114 icon: ReactNode;
115 end?: boolean;
116 count?: number;
117 /** Other path prefixes under which this link is the current one. */
118 also?: string | string[];
119 /** It opens a list of its own: a chevron says so, always or on hover. */
120 drill?: boolean | "hover";
121 /** Whether it is the current row, when the list works that out itself. */
122 current?: boolean;
123 children: ReactNode;
124}) {
125 const { pathname } = useLocation();
126 return (
127 <NavLink
128 to={to}
129 end={end}
130 prefetch="intent"
131 className={({ isActive, isPending }) => {
132 const current =
133 lit ??
134 (isActive || [also ?? []].flat().some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/")));
135 return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
136 current
137 ? "bg-raised font-medium text-fg"
138 : isPending
139 ? "bg-raised/60 text-fg"
140 : "text-muted hover:bg-raised/60 hover:text-fg"
141 }`;
142 }}
143 >
144 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
145 <span className="min-w-0 grow truncate">{children}</span>
146 {count != null && count > 0 && (
147 <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
148 {count}
149 </span>
150 )}
151 {drill && (
152 <ChevronRight
153 size={14}
154 aria-hidden="true"
155 className={`-mr-0.5 shrink-0 text-faint transition-opacity ${drill === "hover" ? "opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100" : ""}`}
156 />
157 )}
158 </NavLink>
159 );
160}
161
162/**
163 * Something a project will have, shown where it will live so people can
164 * see where g1t is going. Not a link: nothing is behind it yet.
165 */
166function SidebarSoon({ icon, children, about }: { icon: ReactNode; children: ReactNode; about: string }) {
167 return (
168 <Hint label={about} side="right">
169 <div aria-disabled="true" className="flex h-8 cursor-default items-center gap-2.5 rounded-md px-2 text-[0.8125rem] text-faint">
170 <span className="shrink-0 opacity-70">{icon}</span>
171 <span className="grow truncate">{children}</span>
172 <span className="sr-only">{about}</span>
173 <SoonPill />
174 </div>
175 </Hint>
176 );
177}
178
179/**
180 * A page that is coming, as a link to its roadmap page: faint, with Soon,
181 * and marked current on any of its section's Soon pages.
182 */
183function SidebarSoonLink({
184 to,
185 also,
186 icon,
187 about,
188 current: lit,
189 children,
190}: {
191 to: string;
192 also?: string[];
193 icon: ReactNode;
194 about: string;
195 /** Whether it is the current row, when the list works that out itself. */
196 current?: boolean;
197 children: ReactNode;
198}) {
199 const { pathname } = useLocation();
200 return (
201 // The hint wraps a plain box: Radix's asChild merges className as a
202 // string, which would break NavLink's className function.
203 <Hint label={about} side="right">
204 <div>
205 <NavLink
206 to={to}
207 prefetch="intent"
208 className={({ isActive }) => {
209 const current = lit ?? (isActive || (also ?? []).some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/")));
210 return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
211 current ? "bg-raised font-medium text-fg" : "text-faint hover:bg-raised/60 hover:text-muted"
212 }`;
213 }}
214 >
215 <span className="shrink-0 opacity-80">{icon}</span>
216 <span className="min-w-0 grow truncate">{children}</span>
217 <SoonPill />
218 </NavLink>
219 </div>
220 </Hint>
221 );
222}
223
224/** Icons for what spans projects. */
225const WORKSPACE_ICONS: Record<string, ReactNode> = {
226 board: <KanbanSquare size={15} />,
227 roadmap: <GanttChart size={15} />,
228 teams: <UsersRound size={15} />,
229 packages: <Package size={15} />,
230 fleet: <Bot size={15} />,
231};
232
233/** The Soon pages of a section, as paths, so its link is current on them. */
234function soonPaths(base: string, section: RoadmapItem["section"]): string[] {
235 return roadmapIn(section).map((item) => `${base}/soon/${item.key}`);
236}
237
238function SidebarGroup({
239 title,
240 action,
241 className = "",
242 children,
243}: {
244 title: string;
245 action?: ReactNode;
246 className?: string;
247 children: ReactNode;
248}) {
249 return (
250 <section className={className}>
251 <div className="mb-1 flex h-6 items-center justify-between px-2">
252 <h2 className="text-xs font-medium text-faint">{title}</h2>
253 {action}
254 </div>
255 <div className="space-y-px">{children}</div>
256 </section>
257 );
258}
259
260/** What a workspace is called where people read it: its display name, else its slug. */
261function displayName(membership: Membership): string {
262 return membership.name?.trim() || membership.slug;
263}
264
265function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) {
266 const workspace = shell.workspace;
267 const current = workspace?.slug;
268 const label = workspace ? displayName(workspace) : "Choose a workspace";
269 return (
270 <DropdownMenu>
271 {/* The name goes to the workspace; only the arrows switch it. The
272 name gets all the room there is, and the whole of it on hover. */}
273 <Hint label={workspace ? `${label} · g1t.sh/${workspace.slug}` : undefined}>
274 <Link
275 to={current ? `/${current}` : "/workspaces/new"}
276 prefetch="intent"
277 className="flex h-9 min-w-0 grow items-center gap-2 rounded-md px-2 transition-colors hover:bg-raised"
278 >
279 {workspace ? (
280 <Avatar name={workspace.slug} image={workspace.avatar} size={20} square />
281 ) : (
282 <span className="size-5 shrink-0 rounded-md border border-dashed border-line-strong" />
283 )}
284 <span className="min-w-0 truncate text-[0.8125rem] font-medium">{label}</span>
285 </Link>
286 </Hint>
287 <DropdownMenuTrigger
288 aria-label="Switch workspace"
289 className="flex h-9 w-7 shrink-0 items-center justify-center rounded-md text-faint outline-none transition-colors hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:bg-raised data-[state=open]:text-fg"
290 >
291 <ChevronsUpDown size={14} />
292 </DropdownMenuTrigger>
293 <DropdownMenuContent align="start" className="w-64">
294 {workspace && (
295 <>
296 {/* The workspace's own page: its projects, packages and people. */}
297 <DropdownMenuItem asChild>
298 <Link to={`/${workspace.slug}`}>
299 <LayoutGrid />
300 Workspace overview
301 </Link>
302 </DropdownMenuItem>
303 <DropdownMenuItem asChild>
304 <Link to={`/${workspace.slug}/-/projects`}>
305 <Box />
306 All projects
307 </Link>
308 </DropdownMenuItem>
309 <DropdownMenuSeparator />
310 </>
311 )}
312 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
313 {(user.workspaces ?? []).map((membership) => (
314 <Hint key={membership.slug} label={displayName(membership)} side="right">
315 <DropdownMenuItem asChild>
316 <Link to={`/${membership.slug}`}>
317 <Avatar name={membership.slug} image={membership.avatar} size={24} square />
318 <span className="flex min-w-0 grow flex-col leading-tight">
319 <span className="truncate">{displayName(membership)}</span>
320 <span className="truncate font-mono text-[0.6875rem] text-faint">{membership.slug}</span>
321 </span>
322 <span className="shrink-0 text-[0.6875rem] text-faint capitalize">{membership.role}</span>
323 {membership.slug === current ? (
324 <Check className="shrink-0 text-accent" />
325 ) : (
326 <span className="size-4 shrink-0" aria-hidden="true" />
327 )}
328 </Link>
329 </DropdownMenuItem>
330 </Hint>
331 ))}
332 <DropdownMenuSeparator />
333 <DropdownMenuItem asChild>
334 <Link to="/workspaces/new">
335 <Plus />
336 New workspace
337 </Link>
338 </DropdownMenuItem>
339 </DropdownMenuContent>
340 </DropdownMenu>
341 );
342}
343
344/**
345 * The fine print, as one quiet row at the foot of the account menu and the
346 * visitor's panel: each link a full-height target, left-aligned. In the
347 * menu each is a menu item, so the arrow keys reach it.
348 */
349const LEGAL_LINKS: [string, string][] = [
350 ["Policies", "/policies"],
351 ["Privacy", "/policies/privacy"],
352 ["Security", "/security"],
353];
354const LEGAL_LINK = "flex h-7 items-center rounded px-1.5 text-xs text-faint outline-none transition-colors";
355
356function MenuLegalRow() {
357 return (
358 <div role="group" aria-label="Legal" className="flex items-center gap-0.5 px-0.5 pt-0.5">
359 {LEGAL_LINKS.map(([label, to], index) => (
360 <span key={to} className="flex items-center gap-0.5">
361 {index > 0 && (
362 <span aria-hidden="true" className="text-[0.625rem] text-line-strong">
363 ·
364 </span>
365 )}
366 <DropdownMenuItem asChild className={`${LEGAL_LINK} py-0 data-highlighted:bg-line data-highlighted:text-fg`}>
367 <Link to={to}>{label}</Link>
368 </DropdownMenuItem>
369 </span>
370 ))}
371 </div>
372 );
373}
374
375/** Status's row: the live dot and two or three words, fetched when the menu opens. */
376function StatusSummary({ open }: { open: boolean }) {
377 const status = useSiteStatus(open);
378 const words = statusTitle(status);
379 return (
380 <span className="ml-auto flex min-w-0 items-center gap-1.5 pl-2 text-xs text-faint">
381 {/* The words say it; the dot's own words for screen readers would repeat them. */}
382 <span aria-hidden="true" className="flex">
383 <StatusDot state={status?.overall.state ?? null} />
384 </span>
385 {words ? <span className="truncate">{words}</span> : <Skeleton className="h-3 w-20" />}
386 </span>
387 );
388}
389
390/** A menu row: an icon, words, and whatever sits at its end. */
391const MENU_ROW = "h-9 gap-2.5 px-2.5 text-[0.8125rem]";
392
393function AccountMenu({ user, rail = false }: { user: User; rail?: boolean }) {
394 const submit = useSubmit();
395 const [open, setOpen] = useState(false);
396 // Name, primary address and invites left: asked for once, as soon as the
397 // pointer or focus reaches the button, so they are there when it opens.
398 const details = useFetcher<AccountMenuData | null>({ key: "account-menu" });
399 const prefetch = () => {
400 if (details.state === "idle" && details.data === undefined) details.load("/settings/menu.json");
401 };
402 useEffect(() => {
403 if (open) prefetch();
404 // eslint-disable-next-line react-hooks/exhaustive-deps
405 }, [open]);
406 const me = details.data ?? null;
407 // Still on its way: shapes where the words will be, the same size.
408 const loading = details.data === undefined && details.state !== "idle";
409 // While anyone can sign up, listed once the data says there are invites to look back on.
410 const invitesListed = useInviteOnly() || me?.invites_page === true;
411 const profile = `/u/${user.username}`;
412 return (
413 <DropdownMenu open={open} onOpenChange={setOpen}>
414 {rail ? (
415 // On the rail: the avatar alone, with a dot that says you are here.
416 <DropdownMenuTrigger
417 aria-label={`Account menu for ${user.username}`}
418 onPointerEnter={prefetch}
419 onFocus={prefetch}
420 className="relative 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"
421 >
422 <Avatar name={user.username} image={user.avatar} size={34} />
423 <span aria-hidden="true" className="absolute -right-0.5 -bottom-0.5 size-3 rounded-full bg-success ring-[2.5px] ring-[#0b0b0d]" />
424 </DropdownMenuTrigger>
425 ) : (
426 <DropdownMenuTrigger
427 aria-label={`Account menu for ${user.username}`}
428 onPointerEnter={prefetch}
429 onFocus={prefetch}
430 className="flex h-10 w-full items-center gap-2.5 rounded-md px-2 text-left outline-none transition-colors hover:bg-raised focus-visible:ring-2 focus-visible:ring-accent/60 data-[state=open]:bg-raised"
431 >
432 <Avatar name={user.username} image={user.avatar} size={22} />
433 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
434 <ChevronsUpDown size={14} className="shrink-0 text-faint" />
435 </DropdownMenuTrigger>
436 )}
437 <DropdownMenuContent align={rail ? "end" : "start"} side={rail ? "right" : "top"} collisionPadding={8} className="w-[17.5rem] p-1.5">
438 {/* Who is signed in, and a way to their profile. */}
439 <DropdownMenuItem asChild className="gap-3 px-2 py-2">
440 <Link to={profile} aria-label={`${me?.name ?? user.username} (@${user.username}), your profile`}>
441 <Avatar name={user.username} image={user.avatar} size={36} />
442 <span className="flex min-w-0 grow flex-col leading-tight" aria-busy={loading}>
443 {loading ? (
444 <Skeleton className="my-[0.1875rem] h-3.5 w-28" />
445 ) : (
446 <span className="truncate text-sm font-medium text-fg">{me?.name ?? user.username}</span>
447 )}
448 <span className="truncate font-mono text-xs text-muted">@{user.username}</span>
449 {loading ? (
450 <Skeleton className="mt-1 h-3 w-40" />
451 ) : (
452 me?.email && <span className="mt-0.5 truncate text-xs text-faint">{me.email}</span>
453 )}
454 </span>
455 </Link>
456 </DropdownMenuItem>
457 <DropdownMenuSeparator className="my-1.5" />
458 <DropdownMenuGroup>
459 <DropdownMenuItem asChild className={MENU_ROW}>
460 <Link to={profile}>
461 <CircleUserRound />
462 Your profile
463 </Link>
464 </DropdownMenuItem>
465 <DropdownMenuItem asChild className={MENU_ROW}>
466 <Link to={FIRST_SETTINGS_PAGE}>
467 <Settings />
468 Your settings
469 </Link>
470 </DropdownMenuItem>
471 {invitesListed && (
472 <DropdownMenuItem asChild className={MENU_ROW}>
473 <Link to="/settings/invites">
474 <Ticket />
475 {ACCOUNT_SETTINGS.invites.title}
476 {loading && <Skeleton className="ml-auto h-4 w-12 rounded-full" />}
477 {me?.invites_left != null && (
478 <span className="ml-auto rounded-full bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
479 {me.invites_left} left
480 </span>
481 )}
482 </Link>
483 </DropdownMenuItem>
484 )}
485 </DropdownMenuGroup>
486 <DropdownMenuSeparator className="my-1.5" />
487 <DropdownMenuGroup>
488 <DropdownMenuItem asChild className={MENU_ROW}>
489 <a href="https://docs.g1t.sh/">
490 <BookOpen />
491 Documentation
492 </a>
493 </DropdownMenuItem>
494 <DropdownMenuItem asChild className={MENU_ROW}>
495 <Link to="/support">
496 <LifeBuoy />
497 Support
498 </Link>
499 </DropdownMenuItem>
500 <DropdownMenuItem asChild className={MENU_ROW}>
501 <a href={STATUS_URL}>
502 <Activity />
503 Status
504 <StatusSummary open={open} />
505 </a>
506 </DropdownMenuItem>
507 </DropdownMenuGroup>
508 <DropdownMenuSeparator className="my-1.5" />
509 {/* Submitted from here: the menu closes on select, and a button
510 that has left the page cannot submit a form. */}
511 <DropdownMenuItem className={MENU_ROW} onSelect={() => submit(null, { method: "post", action: "/logout" })}>
512 <LogOut />
513 Sign out
514 </DropdownMenuItem>
515 <DropdownMenuSeparator className="my-1.5" />
516 <MenuLegalRow />
517 </DropdownMenuContent>
518 </DropdownMenu>
519 );
520}
521
522/**
523 * A workspace's settings pages, which the sidebar drills into: how it is
524 * set up and connected (guardrails, secrets, runners, integrations,
525 * webhooks), what it pays, its repositories, tokens and record. The main
526 * list keeps the places work happens and who belongs; every member can
527 * still open these.
528 */
529const SETTINGS_PAGE =
530 new RegExp(`^/([^/]+)/-/(${SETTINGS_PAGES.join("|")})(/|$)`);
531/** A project's settings pages, which the project's menu drills into. */
532const REPO_SETTINGS_PAGE = /^\/([^/]+)\/([^/-][^/]*)\/settings(\/|$)/;
533
534/**
535 * The sidebar's lists are a stack, the way a phone pushes a screen: one
536 * drilled into slides in from the right over the full width, while the one
537 * it covers drifts a quarter of the way left and fades, both on one long
538 * ease-out, the fade quicker than the move so the two never blur together.
539 * Going back reverses it.
540 */
541const LAYER =
542 "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";
543/** One list, filling its layer. */
544const PANEL = "h-full overflow-y-auto px-2 pb-4";
545
546/** One list in the stack: what it is, and its rows. */
547type Level = { key: string; node: ReactNode };
548
549/** True once the page has hydrated, so what the server drew never animates in. */
550function useHydrated(): boolean {
551 const [hydrated, setHydrated] = useState(false);
552 useEffect(() => setHydrated(true), []);
553 return hydrated;
554}
555
556/**
557 * Drilling in and out of the sidebar's lists. `trail` is the way from the
558 * main list to the one shown, worked out from the address, so a link
559 * straight to a settings page opens the sidebar already drilled in, and
560 * leaving it comes back out. Lists deeper than the one shown stay in
561 * place, off to the right, so going back slides them away rather than
562 * dropping them. Moving to a list at the same depth (a workspace's settings
563 * to a project) fades the new one in where it is.
564 */
565function Drill({ trail }: { trail: Level[] }) {
566 const depth = trail.length - 1;
567 const hydrated = useHydrated();
568 const stack = useRef<Level[]>(trail);
569 const before = stack.current;
570 const within = trail.every((level, index) => before[index]?.key === level.key);
571 stack.current = within ? [...trail, ...before.slice(trail.length)] : trail;
572 const levels = stack.current;
573 // What was showing last time, to tell drilling in from moving across.
574 const shown = useRef({ depth, keys: levels.map((level) => level.key) });
575 useEffect(() => {
576 shown.current = { depth, keys: levels.map((level) => level.key) };
577 });
578 return (
579 <div className="relative min-h-0 grow overflow-hidden">
580 {levels.map((level, index) => {
581 const current = index === depth;
582 const place =
583 index < depth
584 ? "pointer-events-none -translate-x-1/4 opacity-0"
585 : current
586 ? "translate-x-0 opacity-100"
587 : "pointer-events-none translate-x-full opacity-0";
588 // A list that was not there before arrives: from the right when
589 // drilling in, in place when moving across.
590 const arriving = hydrated && current && !shown.current.keys.includes(level.key);
591 const enter = arriving
592 ? depth > shown.current.depth
593 ? "starting:translate-x-full starting:opacity-0"
594 : "starting:opacity-0"
595 : "";
596 return (
597 <div key={level.key} className={`${LAYER} ${place} ${enter}`} inert={!current} aria-hidden={!current || undefined}>
598 {level.node}
599 </div>
600 );
601 })}
602 </div>
603 );
604}
605
606/**
607 * The row at the top of a list drilled into: the list's name, which goes
608 * back out, and what it belongs to, faint at the end.
609 */
610function BackRow({ to, label, context }: { to: string; label: ReactNode; context?: ReactNode }) {
611 return (
612 <Link
613 to={to}
614 prefetch="intent"
615 className="group mt-3 flex h-8 items-center gap-1.5 rounded-md pr-2 pl-1 text-[0.8125rem] font-medium text-fg transition-colors hover:bg-raised/60"
616 >
617 <ChevronLeft size={16} className="shrink-0 text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
618 <span className="min-w-0 shrink-0 truncate">{label}</span>
619 {context && (
620 <span className="ml-auto min-w-0 truncate pl-2 font-mono text-[0.6875rem] font-normal text-faint">{context}</span>
621 )}
622 </Link>
623 );
624}
625
626/** A thin rule between groups of a list. */
627function Rule() {
628 return <div role="separator" className="mx-2 my-2.5 h-px bg-line" />;
629}
630
631/** A quiet heading inside a group: Pinned, Recent. */
632function SidebarSubhead({ children }: { children: ReactNode }) {
633 return <h3 className="px-2 pt-1.5 pb-0.5 text-[0.6875rem] font-medium tracking-wide text-faint">{children}</h3>;
634}
635
636/**
637 * The workspace's projects as the sidebar keeps them, however many there
638 * are: the person's pins in their order, what they opened last, and the
639 * way to all of them. Pins move by dragging, or with Alt and the arrow
640 * keys; the projects service keeps the order.
641 */
642function SidebarProjects({ slug, shell, current }: { slug: string; shell: ShellData; current: boolean }) {
643 const reorder = useFetcher({ key: `pins:${slug}` });
644 const saved = shell.pinned ?? [];
645 // While a new order is on its way, it shows as made.
646 const asked = reorder.formData?.get("intent") === "reorder" ? reorder.formData.getAll("slug").map(String) : null;
647 const pinned = asked
648 ? asked.map((name) => saved.find((project) => project.name === name)).filter((project): project is ShortcutProject => project != null)
649 : saved;
650 const active = shell.repo ? { namespace: shell.repo.namespace, name: shell.repo.name, isPrivate: false } : null;
651 const recent = recentWith(shell.recent ?? [], pinned, active, slug);
652 const [dragging, setDragging] = useState<number | null>(null);
653 const move = (from: number, by: number) => {
654 const next = movedPin(pinned, from, by);
655 if (!next) return;
656 const form = new FormData();
657 form.set("intent", "reorder");
658 for (const project of next) form.append("slug", project.name);
659 reorder.submit(form, { method: "post", action: `/${slug}/-/pins` });
660 };
661 // Each row pins or unpins in place: the pin shows on hover or focus, and
662 // stays shown on a pinned row's hover so it reads as "unpin".
663 const row = (project: ShortcutProject, isPinned: boolean) => (
664 <div className="group/row relative">
665 <SidebarLink to={`/${project.namespace}/${project.name}`} icon={project.isPrivate ? <Lock size={15} /> : <Box size={15} />} drill="hover">
666 <span className="block truncate pr-6">{project.title ?? project.name}</span>
667 </SidebarLink>
668 <PinButton
669 workspace={slug}
670 slug={project.name}
671 name={project.title ?? project.name}
672 pinned={isPinned}
673 small
674 className="absolute top-1/2 right-6 -translate-y-1/2 opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100"
675 />
676 </div>
677 );
678 return (
679 <SidebarGroup
680 title="Projects"
681 action={
682 <Link to={`/new?workspace=${slug}`} aria-label="New project" className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg">
683 <Plus size={13} />
684 </Link>
685 }
686 >
687 {/* Only there once something is pinned: each row below has its own pin. */}
688 {pinned.length > 0 && (
689 <>
690 <SidebarSubhead>Pinned</SidebarSubhead>
691 <ul aria-label="Pinned projects" className="space-y-px">
692 {pinned.map((project, index) => (
693 <li
694 key={project.name}
695 draggable={pinned.length > 1}
696 onDragStart={(event) => {
697 setDragging(index);
698 event.dataTransfer.effectAllowed = "move";
699 }}
700 onDragOver={(event) => {
701 if (dragging != null) event.preventDefault();
702 }}
703 onDrop={(event) => {
704 event.preventDefault();
705 if (dragging != null && dragging !== index) move(dragging, index - dragging);
706 setDragging(null);
707 }}
708 onDragEnd={() => setDragging(null)}
709 onKeyDown={(event) => {
710 if (!event.altKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
711 event.preventDefault();
712 move(index, event.key === "ArrowUp" ? -1 : 1);
713 }}
714 className={`group/pin relative ${dragging === index ? "opacity-50" : ""}`}
715 >
716 {row(project, true)}
717 {pinned.length > 1 && (
718 <Hint label="Drag, or Alt and an arrow key, to reorder" side="right">
719 <span className="absolute top-1/2 -left-1.5 -translate-y-1/2 cursor-grab text-faint opacity-0 transition-opacity group-hover/pin:opacity-100">
720 <GripVertical size={12} aria-hidden="true" />
721 </span>
722 </Hint>
723 )}
724 </li>
725 ))}
726 </ul>
727 </>
728 )}
729 {recent.length > 0 && (
730 <>
731 <SidebarSubhead>Recent</SidebarSubhead>
732 {recent.map((project) => (
733 <div key={`${project.namespace}/${project.name}`}>{row(project, false)}</div>
734 ))}
735 </>
736 )}
737 <div className="pt-1">
738 <SidebarLink to={`/${slug}/-/projects`} icon={<LayoutGrid size={15} />} count={shell.repos.length} current={current}>
739 All projects
740 </SidebarLink>
741 </div>
742 </SidebarGroup>
743 );
744}
745
746/**
747 * The main list, in two parts a rule apart. Above it, what is yours
748 * whichever workspace you are in: Mission control, your inbox, and what
749 * others have shared with you. Below it, under the workspace's name, the
750 * workspace: its overview, its projects, what it builds and runs with
751 * across them, then its people, usage, support and settings. One row is
752 * lit wherever you are (lib/workspace-nav.ts).
753 */
754function MainMenu({ user, shell }: { user: User | null; shell: ShellData }) {
755 const ws = shell.workspace;
756 const { pathname } = useLocation();
757 const going = useNavigation().location?.pathname;
758 // The row lights as the link is followed, not once the page arrives.
759 const here = sidebarCurrent(going ?? pathname, ws?.slug ?? null);
760 // A visitor browses: no workspace, no projects of their own.
761 if (!user) {
762 return (
763 <nav aria-label="g1t" className={PANEL}>
764 <div className="mt-3 space-y-px">
765 {VISITOR_LINKS.map((link) => (
766 <SidebarLink key={link.to} to={link.to} icon={link.to === "/search" ? <Search size={15} /> : <Compass size={15} />}>
767 {link.label}
768 </SidebarLink>
769 ))}
770 </div>
771 </nav>
772 );
773 }
774 const shared = shell.shared ?? [];
775 return (
776 <nav aria-label="g1t" className={PANEL}>
777 {/* Yours, in every workspace. Explore, all of g1t, is in the top bar. */}
778 <div className="mt-3 space-y-px">
779 <SidebarLink to="/" end icon={<House size={15} />} current={here === "mission"}>
780 Mission control
781 </SidebarLink>
782 <SidebarLink to="/inbox" icon={<Inbox size={15} />} count={shell.inbox?.unread ?? undefined} current={here === "inbox"}>
783 Inbox
784 </SidebarLink>
785 </div>
786 {shared.length > 0 && (
787 <SidebarGroup title="Shared with you" className="mt-3">
788 {shared.map((repo) => (
789 <SidebarLink
790 key={`${repo.namespace}/${repo.name}`}
791 to={`/${repo.namespace}/${repo.name}`}
792 icon={repo.isPrivate ? <Lock size={15} /> : <Box size={15} />}
793 drill="hover"
794 >
795 <span className="font-mono text-faint">{repo.namespace}/</span>
796 {repo.name}
797 </SidebarLink>
798 ))}
799 </SidebarGroup>
800 )}
801
802 <Rule />
803 {ws ? (
804 <>
805 {/* The workspace the switcher names: its own page first. */}
806 <SidebarGroup title={displayName(ws)}>
807 <SidebarLink to={`/${ws.slug}`} end icon={<LayoutDashboard size={15} />} current={here === "overview"}>
808 Overview
809 </SidebarLink>
810 </SidebarGroup>
811 <div className="mt-3">
812 <SidebarProjects slug={ws.slug} shell={shell} current={here === "projects"} />
813 </div>
814
815 <Rule />
816 <div className="space-y-px">
817 <SidebarLink to={`/${ws.slug}/-/agents`} icon={<Bot size={15} />} current={here === "agents"}>
818 Agent fleet
819 </SidebarLink>
820 <SidebarLink to={`/${ws.slug}/-/context`} icon={<Network size={15} />} current={here === "context"}>
821 Context
822 </SidebarLink>
823 <SidebarLink to={`/${ws.slug}/-/memory`} icon={<Brain size={15} />} current={here === "memory"}>
824 Memory
825 </SidebarLink>
826 <SidebarLink to={`/${ws.slug}/-/security`} icon={<ShieldCheck size={15} />} current={here === "security"}>
827 Security
828 </SidebarLink>
829 <SidebarLink to={`/${ws.slug}/-/packages`} icon={<Package size={15} />} current={here === "packages"}>
830 Packages
831 </SidebarLink>
832 {INSIGHTS && (
833 <SidebarSoonLink
834 to={`/${ws.slug}/-/insights`}
835 icon={<TrendingUp size={15} />}
836 about={INSIGHTS.summary}
837 current={here === "insights"}
838 >
839 {INSIGHTS.title}
840 </SidebarSoonLink>
841 )}
842 {roadmapIn("Workspace").filter((item) => item.key !== "insights").map((item) => (
843 <SidebarSoonLink
844 key={item.key}
845 to={`/${ws.slug}/-/soon/${item.key}`}
846 icon={WORKSPACE_ICONS[item.key] ?? <Sparkles size={15} />}
847 about={item.summary}
848 >
849 {item.title === "Board" ? "Boards" : item.title}
850 </SidebarSoonLink>
851 ))}
852 </div>
853
854 <Rule />
855 <div className="space-y-px">
856 {/* Who belongs, for every member to see; owners invite and manage there. */}
857 <SidebarLink to={`/${ws.slug}/-/people`} icon={<Users size={15} />} current={here === "people"}>
858 People
859 </SidebarLink>
860 <SidebarLink to={`/${ws.slug}/-/teams`} icon={WORKSPACE_ICONS.teams} current={here === "teams"}>
861 Teams
862 </SidebarLink>
863 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />} current={here === "usage"}>
864 Usage
865 </SidebarLink>
866 <SidebarLink to="/support" icon={<LifeBuoy size={15} />} current={here === "support"}>
867 Support
868 </SidebarLink>
869 {/* How it is set up and connected, what it pays and its record: a list of their own. */}
870 <SidebarLink
871 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/repositories`}
872 icon={<Settings size={15} />}
873 drill
874 current={here === "settings"}
875 >
876 Settings
877 </SidebarLink>
878 </div>
879 </>
880 ) : (
881 <SidebarGroup title="Projects">
882 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
883 </SidebarGroup>
884 )}
885 </nav>
886 );
887}
888
889/** Insights, coming: the workspace's own page says what it will be. */
890const INSIGHTS = roadmapItem("insights");
891
892/** A workspace's settings, drilled into from Settings in the main list. */
893function SettingsMenu({ slug, owner }: { slug: string; owner: boolean }) {
894 return (
895 <nav aria-label="Workspace settings" className={PANEL}>
896 <BackRow to={`/${slug}`} label="Settings" context={slug} />
897 <div className="mt-2 space-y-px">
898 {owner && (
899 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
900 General
901 </SidebarLink>
902 )}
903 <SidebarLink to={`/${slug}/-/repositories`} icon={<BookMarked size={15} />}>
904 Repositories
905 </SidebarLink>
906 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
907 Access tokens
908 </SidebarLink>
909 {owner && (
910 <SidebarLink to={`/${slug}/-/personal-access-tokens`} icon={<UserRoundKey size={15} />}>
911 Personal access tokens
912 </SidebarLink>
913 )}
914 <SidebarLink to={`/${slug}/-/rules`} icon={<Scale size={15} />}>
915 Rules
916 </SidebarLink>
917 </div>
918 <SidebarGroup title="Agents and runs" className="mt-3">
919 <SidebarLink to={`/${slug}/-/guardrails`} icon={<Gauge size={15} />}>
920 Guardrails
921 </SidebarLink>
922 <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}>
923 Secrets and variables
924 </SidebarLink>
925 <SidebarLink to={`/${slug}/-/actions`} icon={<PlayCircle size={15} />}>
926 Actions
927 </SidebarLink>
928 {owner && (
929 <SidebarLink to={`/${slug}/-/runners`} icon={<ServerCog size={15} />}>
930 Runners
931 </SidebarLink>
932 )}
933 </SidebarGroup>
934 <SidebarGroup title="Connections" className="mt-3">
935 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}>
936 Integrations
937 </SidebarLink>
938 <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
939 Webhooks
940 </SidebarLink>
941 </SidebarGroup>
942 <Rule />
943 <div className="space-y-px">
944 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
945 Billing and plans
946 </SidebarLink>
947 <SidebarLink to={`/${slug}/-/audit`} icon={<History size={15} />}>
948 Audit log
949 </SidebarLink>
950 </div>
951 </nav>
952 );
953}
954
955/**
956 * What the repository menu needs. Known from the address before the page's
957 * data arrives, so the menu is right from the first frame; the counts fill
958 * in once it does.
959 */
960type MenuRepo = {
961 namespace: string;
962 name: string;
963 member: boolean;
964 /** Whether they see its settings; from the repository's page once it loads. */
965 settings?: boolean;
966 /** What they may do there, once the repository's page loads. */
967 can?: Abilities;
968 issues?: number;
969 pulls?: number;
970};
971
972function sameRepo(a: { namespace: string; name: string } | null, b: { namespace: string; name: string } | null) {
973 return (
974 a != null &&
975 b != null &&
976 a.namespace.toLowerCase() === b.namespace.toLowerCase() &&
977 a.name.toLowerCase() === b.name.toLowerCase()
978 );
979}
980
981/**
982 * A project's own list, drilled into while you are in it: everything about
983 * the project, running and its code, and nothing else, with the way back
984 * out to all of them.
985 */
986function RepoMenu({
987 repo,
988 isPrivate,
989 back,
990}: {
991 repo: MenuRepo;
992 isPrivate: boolean;
993 /** Where the way back leads: the workspace's projects, mission control, or Explore. */
994 back: { to: string; label: string };
995}) {
996 const base = `/${repo.namespace}/${repo.name}`;
997 // What a member sees, and what everyone who can see the project does.
998 const shows = new Set(projectPages(repo.member, repo.can));
999 return (
1000 <nav aria-label={`${repo.namespace}/${repo.name}`} className={PANEL}>
1001 <BackRow to={back.to} label={back.label} />
1002 <Hint label="Overview" side="right">
1003 <div>
1004 <NavLink
1005 to={base}
1006 end
1007 prefetch="intent"
1008 className={({ isActive }) =>
1009 `mt-2 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors ${isActive ? "bg-raised" : "hover:bg-raised/60"}`
1010 }
1011 >
1012 <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
1013 {isPrivate ? <Lock size={13} /> : <Box size={13} />}
1014 </span>
1015 <span className="min-w-0 truncate font-mono text-[0.8125rem]">
1016 <span className="text-faint">{repo.namespace}/</span>
1017 <span className="font-semibold text-fg">{repo.name}</span>
1018 </span>
1019 </NavLink>
1020 </div>
1021 </Hint>
1022 {/* A project's pages, in the order people use them. A page with more
1023 than one view shows them as tabs across its top. */}
1024 <Rule />
1025 <div className="space-y-px">
1026 <SidebarLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`, `${base}/branches`, `${base}/tags`, `${base}/compare`, ...soonPaths(base, "Code")]} icon={<Code2 size={15} />}>
1027 Code
1028 </SidebarLink>
1029 <SidebarLink to={`${base}/issues`} also={[`${base}/plans`, `${base}/milestones`, `${base}/labels`, ...soonPaths(base, "Issues")]} icon={<CircleDot size={15} />} count={repo.issues}>
1030 Issues
1031 </SidebarLink>
1032 <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/queue`]} icon={<GitPullRequest size={15} />} count={repo.pulls}>
1033 Pull requests
1034 </SidebarLink>
1035 <SidebarLink to={`${base}/agents`} also={[`${base}/sessions`, `${base}/memory`, ...soonPaths(base, "Agents")]} icon={<Bot size={15} />}>
1036 Agents
1037 </SidebarLink>
1038 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
1039 Workflows
1040 </SidebarLink>
1041 </div>
1042 <Rule />
1043 <div className="space-y-px">
1044 {shows.has("deployments") ? (
1045 <SidebarLink to={`${base}/deployments`} also={soonPaths(base, "Deployments")} icon={<Rocket size={15} />}>
1046 Deployments
1047 </SidebarLink>
1048 ) : null}
1049 <SidebarSoonLink to={`${base}/soon/logs`} also={soonPaths(base, "Observability")} icon={<Activity size={15} />} about="Logs, errors, uptime and analytics of the project's deployed apps.">
1050 Observability
1051 </SidebarSoonLink>
1052 {shows.has("security") ? (
1053 <SidebarLink to={`${base}/security`} also={soonPaths(base, "Security")} icon={<ShieldCheck size={15} />}>
1054 Security
1055 </SidebarLink>
1056 ) : null}
1057 <SidebarLink
1058 to={`${base}/contributors`}
1059 also={[`${base}/activity`, `${base}/stargazers`, ...soonPaths(base, "Insights")]}
1060 icon={<BarChart3 size={15} />}
1061 >
1062 Insights
1063 </SidebarLink>
1064 </div>
1065 {(repo.settings ?? shows.has("settings")) && (
1066 <>
1067 <Rule />
1068 <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />} drill>
1069 Settings
1070 </SidebarLink>
1071 </>
1072 )}
1073 </nav>
1074 );
1075}
1076
1077/**
1078 * A project's settings, drilled into from its list: what is about running
1079 * it, then its agents, then its code and who can reach it. The way back
1080 * leads to the project.
1081 */
1082function RepoSettingsMenu({ repo }: { repo: MenuRepo }) {
1083 const base = `/${repo.namespace}/${repo.name}`;
1084 // Each page shows to the roles that can use it, once the role is known.
1085 const shows = (page: string) => !repo.can || repo.can[SETTINGS_CAPABILITY[page] ?? "administer"];
1086 const running = shows("deployments");
1087 const agents = shows("agents") || shows("guardrails");
1088 return (
1089 <nav aria-label={`${repo.namespace}/${repo.name} settings`} className={PANEL}>
1090 <BackRow to={base} label="Settings" context={`${repo.namespace}/${repo.name}`} />
1091 {shows("") && (
1092 <div className="mt-2 space-y-px">
1093 <SidebarLink to={`${base}/settings`} end icon={<Settings size={15} />}>
1094 General
1095 </SidebarLink>
1096 </div>
1097 )}
1098 {running && (
1099 <>
1100 {shows("") && <Rule />}
1101 <div className={shows("") ? "space-y-px" : "mt-2 space-y-px"}>
1102 <SidebarLink to={`${base}/settings/deployments`} icon={<Rocket size={15} />}>
1103 Deployments
1104 </SidebarLink>
1105 <SidebarLink to={`${base}/settings/domains`} icon={<Globe size={15} />}>
1106 Domains
1107 </SidebarLink>
1108 <SidebarLink to={`${base}/settings/dependencies`} icon={<Network size={15} />}>
1109 Dependencies
1110 </SidebarLink>
1111 </div>
1112 </>
1113 )}
1114 {agents && (
1115 <>
1116 <Rule />
1117 <div className="space-y-px">
1118 {shows("agents") && (
1119 <SidebarLink to={`${base}/settings/agents`} icon={<Bot size={15} />}>
1120 Agents
1121 </SidebarLink>
1122 )}
1123 {shows("guardrails") && (
1124 <SidebarLink to={`${base}/settings/guardrails`} icon={<ShieldCheck size={15} />}>
1125 Guardrails
1126 </SidebarLink>
1127 )}
1128 </div>
1129 </>
1130 )}
1131 <Rule />
1132 <div className="space-y-px">
1133 {shows("repository") && (
1134 <SidebarLink to={`${base}/settings/repository`} icon={<BookMarked size={15} />}>
1135 Repository
1136 </SidebarLink>
1137 )}
1138 {shows("access") && (
1139 <SidebarLink to={`${base}/settings/access`} icon={<Users size={15} />}>
1140 Access
1141 </SidebarLink>
1142 )}
1143 {shows("keys") && (
1144 <SidebarLink to={`${base}/settings/keys`} icon={<KeyRound size={15} />}>
1145 Deploy keys
1146 </SidebarLink>
1147 )}
1148 {shows("branches") && (
1149 <SidebarLink to={`${base}/settings/branches`} icon={<GitBranch size={15} />}>
1150 Branches and merging
1151 </SidebarLink>
1152 )}
1153 {shows("rules") && (
1154 <SidebarLink to={`${base}/settings/rules`} icon={<Scale size={15} />}>
1155 Rules
1156 </SidebarLink>
1157 )}
1158 {shows("secrets") && (
1159 <SidebarLink to={`${base}/settings/secrets`} icon={<Lock size={15} />}>
1160 Secrets and variables
1161 </SidebarLink>
1162 )}
1163 {shows("actions") && (
1164 <SidebarLink to={`${base}/settings/actions`} icon={<PlayCircle size={15} />}>
1165 Actions
1166 </SidebarLink>
1167 )}
1168 {shows("environments") && (
1169 <SidebarLink to={`${base}/settings/environments`} icon={<Layers size={15} />}>
1170 Environments
1171 </SidebarLink>
1172 )}
1173 {shows("runners") && (
1174 <SidebarLink to={`${base}/settings/runners`} icon={<ServerCog size={15} />}>
1175 Runners
1176 </SidebarLink>
1177 )}
1178 {shows("webhooks") && (
1179 <SidebarLink to={`${base}/settings/webhooks`} icon={<Webhook size={15} />}>
1180 Webhooks
1181 </SidebarLink>
1182 )}
1183 </div>
1184 </nav>
1185 );
1186}
1187
1188/**
1189 * Whether the menus list Settings → Invites (invites to g1t): always while
1190 * sign-up takes an invite; once anyone can sign up, only for someone with
1191 * invites already made to look back on, which the account menu's data says.
1192 */
1193function useInvitesListed(): boolean {
1194 const inviteOnly = useInviteOnly();
1195 const details = useFetcher<AccountMenuData | null>({ key: "account-menu" });
1196 useEffect(() => {
1197 if (!inviteOnly && details.state === "idle" && details.data === undefined) details.load("/settings/menu.json");
1198 // eslint-disable-next-line react-hooks/exhaustive-deps
1199 }, [inviteOnly]);
1200 return inviteOnly || details.data?.invites_page === true;
1201}
1202
1203/** Your own settings, drilled into from Your settings: one page each. */
1204function AccountSettingsMenu({ username }: { username: string }) {
1205 const invites = useInvitesListed();
1206 const link = (page: AccountSettingsPage, icon: ReactNode) => (
1207 <SidebarLink to={`/settings/${page}`} icon={icon}>
1208 {ACCOUNT_SETTINGS[page].title}
1209 </SidebarLink>
1210 );
1211 return (
1212 <nav aria-label="Your settings" className={PANEL}>
1213 <BackRow to="/" label="Your settings" context={username} />
1214 <div className="mt-2 space-y-px">
1215 {link("profile", <CircleUserRound size={15} />)}
1216 {link("emails", <Mail size={15} />)}
1217 {invites && link("invites", <Ticket size={15} />)}
1218 </div>
1219 <Rule />
1220 <div className="space-y-px">
1221 {link("keys", <Fingerprint size={15} />)}
1222 {link("tokens", <KeyRound size={15} />)}
1223 </div>
1224 <Rule />
1225 <div className="space-y-px">
1226 {link("github", <GithubMark className="size-[15px]" />)}
1227 {link("applications", <Plug size={15} />)}
1228 </div>
1229 <Rule />
1230 <div className="space-y-px">
1231 {link("two-factor", <ShieldCheck size={15} />)}
1232 {link("security-log", <History size={15} />)}
1233 </div>
1234 </nav>
1235 );
1236}
1237
1238/**
1239 * Signing in and signing up, in place of the account for a visitor. Signing
1240 * in brings them back to the page they are on.
1241 */
1242/** Help, status and the fine print under a visitor's sign-in buttons. */
1243function VisitorLinks() {
1244 const status = useSiteStatus();
1245 const quiet = `${LEGAL_LINK} hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/60`;
1246 const dot = (
1247 <span aria-hidden="true" className="text-[0.625rem] text-line-strong">
1248 ·
1249 </span>
1250 );
1251 return (
1252 // Two short rows, so a narrow sidebar never starts a line with a dot.
1253 <nav aria-label="About g1t" className="pt-0.5">
1254 <div className="flex items-center gap-0.5">
1255 <a href={STATUS_URL} className={`${quiet} gap-1.5`}>
1256 <StatusDot state={status?.overall.state ?? null} />
1257 Status
1258 </a>
1259 {dot}
1260 <Link to="/support" className={quiet}>
1261 Support
1262 </Link>
1263 </div>
1264 <div className="flex items-center gap-0.5">
1265 {LEGAL_LINKS.map(([label, to], index) => (
1266 <span key={to} className="flex items-center gap-0.5">
1267 {index > 0 && dot}
1268 <Link to={to} className={quiet}>
1269 {label}
1270 </Link>
1271 </span>
1272 ))}
1273 </div>
1274 </nav>
1275 );
1276}
1277
1278function VisitorPanel() {
1279 const { pathname, search } = useLocation();
1280 const signUp = useSignUpCopy();
1281 return (
1282 <div className="space-y-2">
1283 <p className="px-1 text-xs text-muted">Sign in to open issues, review pull requests and run agents.</p>
1284 <div className="grid grid-cols-2 gap-2">
1285 <Link
1286 to={withNext("/login", pathname + search)}
1287 className="flex h-9 items-center justify-center gap-1.5 rounded-md border border-line text-[0.8125rem] font-medium text-fg/90 transition-colors hover:border-line-strong hover:bg-raised hover:text-fg"
1288 >
1289 <LogIn size={14} />
1290 Sign in
1291 </Link>
1292 <Link
1293 to={withNext("/register", pathname + search)}
1294 className="flex h-9 items-center justify-center rounded-md bg-fg text-[0.8125rem] font-medium text-bg transition-colors hover:bg-white"
1295 >
1296 {signUp.primary}
1297 </Link>
1298 </div>
1299 <VisitorLinks />
1300 </div>
1301 );
1302}
1303
1304function Sidebar({
1305 user,
1306 shell,
1307 missing = false,
1308 onFind,
1309 onClose,
1310 rail = false,
1311}: {
1312 user: User | null;
1313 shell: ShellData;
1314 /** The page is a 404: the address names nothing the viewer can see. */
1315 missing?: boolean;
1316 onFind: () => void;
1317 /** In the sheet on a small screen: closing it, at the end of the top row. */
1318 onClose?: () => void;
1319 /**
1320 * Beside the rail, as Code's sidebar: code only, under a Code heading.
1321 * The workspace, its settings and the account are the rail's.
1322 */
1323 rail?: boolean;
1324}) {
1325 const ws = shell.workspace;
1326 const { pathname } = useLocation();
1327 const going = useNavigation().location?.pathname;
1328 // Where the sidebar is drilled to follows the page being gone to, so it
1329 // moves as the link is followed, not once the page arrives.
1330 const target = going ?? pathname;
1331 // On a 404, the address is not a place: no list is drilled into from it,
1332 // except the project's own when the project is real and visible and only
1333 // something inside it is missing (a file, a commit, an issue).
1334 const lost = missing && going == null;
1335 const inSettings = !lost && ws != null && SETTINGS_PAGE.exec(target)?.[1]?.toLowerCase() === ws.slug;
1336 const inAccount = !lost && target === "/settings" || target.startsWith("/settings/");
1337 const active = shell.repo;
1338 // In a repository, or on the way into one, its own list.
1339 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
1340 const reserved = new Set(["settings", "explore", "search", "new", "u", "pricing", "avatars", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth", "policies", "security", "support", "status", "invite", "inbox", ".well-known"]);
1341 // An invitation to a repository is answered before its menu means anything.
1342 const inRepo =
1343 repoPath != null &&
1344 !reserved.has(repoPath[1]) &&
1345 repoPath[2] !== "-" &&
1346 !/^\/[^/]+\/[^/]+\/invitations\/?$/.test(target) &&
1347 (!lost || sameRepo(active, { namespace: repoPath[1]!, name: repoPath[2]! }));
1348 // A project's settings, one level further in.
1349 const inRepoSettings = inRepo && REPO_SETTINGS_PAGE.test(target);
1350 // The repository the menu is for: the one loaded if it is the one being
1351 // gone to, else what the address says, at once.
1352 const targetRepo = inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null;
1353 const guessed: MenuRepo | null = targetRepo
1354 ? sameRepo(active, targetRepo)
1355 ? active
1356 : {
1357 ...targetRepo,
1358 member: (user?.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()),
1359 }
1360 : null;
1361 // The viewer's role there, from the repository's page once it has loaded.
1362 const page = useRouteLoaderData("routes/repo/layout") as
1363 | { repo?: { namespace: string; name: string }; access?: ViewerAccess }
1364 | undefined;
1365 const menuRepo: MenuRepo | null =
1366 guessed && page?.access && page.repo && sameRepo(page.repo, guessed)
1367 ? { ...guessed, member: page.access.insider, settings: seesSettings(page.access), can: page.access.can }
1368 : guessed;
1369
1370 // The way from the main list to the one shown.
1371 const trail: Level[] = [{ key: "main", node: rail && user && ws ? <CodeMenu shell={shell} slug={ws.slug} /> : <MainMenu user={user} shell={shell} /> }];
1372 if (rail && !menuRepo) {
1373 // Code's own list: nothing to drill into but a project.
1374 } else if (user && inAccount && !rail) {
1375 trail.push({ key: "account", node: <AccountSettingsMenu username={user.username} /> });
1376 } else if (ws && inSettings && !rail) {
1377 trail.push({ key: `settings:${ws.slug}`, node: <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} /> });
1378 } else if (menuRepo) {
1379 const key = `repo:${menuRepo.namespace}/${menuRepo.name}`.toLowerCase();
1380 // Out of a project: to its workspace's projects when they are yours.
1381 const home = (user?.workspaces ?? []).find((m) => m.slug === menuRepo.namespace.toLowerCase());
1382 const back = !user
1383 ? { to: "/explore", label: "Explore" }
1384 : home
1385 ? { to: `/${home.slug}/-/projects`, label: "All projects" }
1386 : { to: "/", label: "Home" };
1387 trail.push({
1388 key,
1389 node: (
1390 <RepoMenu
1391 repo={menuRepo}
1392 isPrivate={[...shell.repos, ...(shell.shared ?? [])].some((repo) => sameRepo(repo, menuRepo) && repo.isPrivate)}
1393 back={back}
1394 />
1395 ),
1396 });
1397 if (inRepoSettings) trail.push({ key: `${key}:settings`, node: <RepoSettingsMenu repo={menuRepo} /> });
1398 }
1399
1400 return (
1401 <div className="flex h-full flex-col">
1402 {/* The same height and rule as the top bar, so the two read as one line. */}
1403 {rail ? (
1404 <ModeHeader title="Code" onClose={onClose} />
1405 ) : (
1406 <div className="flex h-14 shrink-0 items-center gap-1 border-b border-line pr-2 pl-2.5">
1407 {user ? (
1408 <>
1409 {/* The 1 alone beside the workspace: a square hover the height of the
1410 switcher, the mark as tall as the workspace avatar. */}
1411 <Link to="/" aria-label="g1t home" className="flex size-9 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-raised">
1412 <Mark tight className="h-4 w-auto" />
1413 </Link>
1414 <span className="shrink-0 text-line-strong" aria-hidden="true">
1415 /
1416 </span>
1417 <WorkspaceSwitcher user={user} shell={shell} />
1418 </>
1419 ) : (
1420 <Link to="/" aria-label="g1t home" className="mr-auto flex h-9 items-center rounded-md px-2 transition-colors hover:bg-raised">
1421 <Logo className="text-[1.25rem]" />
1422 </Link>
1423 )}
1424 {onClose && (
1425 <button
1426 type="button"
1427 aria-label="Close menu"
1428 onClick={onClose}
1429 className="flex size-9 shrink-0 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg"
1430 >
1431 <X size={16} />
1432 </button>
1433 )}
1434 </div>
1435 )}
1436 <div className="px-2 pt-3">
1437 <button
1438 type="button"
1439 onClick={onFind}
1440 className="flex h-9 w-full items-center gap-2 rounded-md bg-surface px-2.5 text-[0.8125rem] text-faint ring-1 ring-line transition-colors hover:text-muted hover:ring-line-strong"
1441 >
1442 <Search size={14} />
1443 <span className="grow text-left">Search or jump to…</span>
1444 <PaletteKey className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line" />
1445 </button>
1446 </div>
1447 <Drill trail={trail} />
1448 {/* The account, or signing in, as one row at the very bottom; beside the rail, the rail has it. */}
1449 {!rail && (
1450 <div className="shrink-0 border-t border-line p-2">
1451 {user ? <AccountMenu user={user} /> : <VisitorPanel />}
1452 </div>
1453 )}
1454 </div>
1455 );
1456}
1457
1458/** A mode's sidebar heading: the same height and rule as the top bar, with a way to close the phone's sheet. */
1459export function ModeHeader({ title, action, onClose }: { title: ReactNode; action?: ReactNode; onClose?: () => void }) {
1460 return (
1461 <div className="flex h-14 shrink-0 items-center gap-1 border-b border-line pr-2.5 pl-4">
1462 <h2 className="min-w-0 grow truncate text-[0.9375rem] font-semibold">{title}</h2>
1463 {action}
1464 {onClose && (
1465 <button
1466 type="button"
1467 aria-label="Close menu"
1468 onClick={onClose}
1469 className="flex size-8 shrink-0 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg"
1470 >
1471 <X size={16} />
1472 </button>
1473 )}
1474 </div>
1475 );
1476}
1477
1478/**
1479 * Code's sidebar (beside the rail): only code. Its Overview (Mission
1480 * control's code panels), the projects, what spans them, and what is
1481 * coming. The workspace's people, money and settings are Workspace's;
1482 * agents, context and memory are Agents'.
1483 */
1484function CodeMenu({ shell, slug }: { shell: ShellData; slug: string }) {
1485 const { pathname } = useLocation();
1486 const going = useNavigation().location?.pathname;
1487 const path = going ?? pathname;
1488 const at = (page: string) => path === `/${slug}/-/${page}` || path.startsWith(`/${slug}/-/${page}/`);
1489 const shared = shell.shared ?? [];
1490 return (
1491 <nav aria-label="Code" className={PANEL}>
1492 <div className="mt-3 space-y-px">
1493 <SidebarLink to={`/${slug}/-/overview`} icon={<LayoutDashboard size={15} />} current={at("overview")}>
1494 Overview
1495 </SidebarLink>
1496 </div>
1497 <div className="mt-3">
1498 <SidebarProjects slug={slug} shell={shell} current={at("projects")} />
1499 </div>
1500 <Rule />
1501 <div className="space-y-px">
1502 <SidebarLink to={`/${slug}/-/security`} icon={<ShieldCheck size={15} />} current={at("security") && !at("security/settings")}>
1503 Security
1504 </SidebarLink>
1505 <SidebarLink to={`/${slug}/-/packages`} icon={<Package size={15} />} current={at("packages")}>
1506 Packages
1507 </SidebarLink>
1508 {INSIGHTS && (
1509 <SidebarSoonLink to={`/${slug}/-/insights`} icon={<TrendingUp size={15} />} about={INSIGHTS.summary} current={at("insights")}>
1510 {INSIGHTS.title}
1511 </SidebarSoonLink>
1512 )}
1513 {roadmapIn("Workspace")
1514 .filter((item) => item.key !== "insights" && item.key !== "teams" && item.key !== "fleet")
1515 .map((item) => (
1516 <SidebarSoonLink key={item.key} to={`/${slug}/-/soon/${item.key}`} icon={WORKSPACE_ICONS[item.key] ?? <Sparkles size={15} />} about={item.summary}>
1517 {item.title === "Board" ? "Boards" : item.title}
1518 </SidebarSoonLink>
1519 ))}
1520 </div>
1521 {shared.length > 0 && (
1522 <>
1523 <Rule />
1524 <SidebarGroup title="Shared with you">
1525 {shared.map((repo) => (
1526 <SidebarLink
1527 key={`${repo.namespace}/${repo.name}`}
1528 to={`/${repo.namespace}/${repo.name}`}
1529 icon={repo.isPrivate ? <Lock size={15} /> : <Box size={15} />}
1530 drill="hover"
1531 >
1532 <span className="font-mono text-faint">{repo.namespace}/</span>
1533 {repo.name}
1534 </SidebarLink>
1535 ))}
1536 </SidebarGroup>
1537 </>
1538 )}
1539 </nav>
1540 );
1541}
1542
1543/** Workspace pages that sit in its Settings list, drilled into from the Workspace sidebar. */
1544const WORKSPACE_SETTINGS = ["settings", "repositories", "tokens", "personal-access-tokens", "secrets", "actions", "runners", "webhooks", "emoji"];
1545
1546/**
1547 * The Workspace mode's sidebar: the workspace itself, for every member.
1548 * Its overview, people, money, connections, policies and record, then its
1549 * settings as a list of their own. Owner-only pages stay owner-only.
1550 */
1551export function WorkspaceSidebar({ slug, owner, onClose }: { slug: string; owner: boolean; onClose?: () => void }) {
1552 const { pathname } = useLocation();
1553 const going = useNavigation().location?.pathname;
1554 const path = going ?? pathname;
1555 const page = path.startsWith(`/${slug}/-/`) ? path.slice(`/${slug}/-/`.length) : "";
1556 const top = page.split("/")[0] ?? "";
1557 const at = (...pages: string[]) => pages.some((p) => page === p || page.startsWith(`${p}/`));
1558 const inSettings = WORKSPACE_SETTINGS.includes(top);
1559 const main = (
1560 <nav aria-label="Workspace" className={PANEL}>
1561 <div className="mt-3 space-y-px">
1562 <SidebarLink to={`/${slug}/-/workspace`} icon={<LayoutGrid size={15} />} current={at("workspace")}>
1563 Overview
1564 </SidebarLink>
1565 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />} current={at("people")}>
1566 People
1567 </SidebarLink>
1568 <SidebarLink to={`/${slug}/-/teams`} icon={<UsersRound size={15} />} current={at("teams")}>
1569 Teams
1570 </SidebarLink>
1571 </div>
1572 <SidebarGroup title="Usage and billing" className="mt-3">
1573 <SidebarLink to={`/${slug}/-/usage`} icon={<BarChart3 size={15} />} current={at("usage", "gateway")}>
1574 Usage
1575 </SidebarLink>
1576 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />} current={at("billing")}>
1577 Billing and plans
1578 </SidebarLink>
1579 </SidebarGroup>
1580 <SidebarGroup title="Connections" className="mt-3">
1581 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />} current={at("integrations")}>
1582 Integrations
1583 </SidebarLink>
1584 </SidebarGroup>
1585 <SidebarGroup title="Security policies" className="mt-3">
1586 <SidebarLink to={`/${slug}/-/security/settings`} icon={<ShieldCheck size={15} />} current={at("security/settings")}>
1587 Security settings
1588 </SidebarLink>
1589 <SidebarLink to={`/${slug}/-/guardrails`} icon={<Gauge size={15} />} current={at("guardrails")}>
1590 Guardrails
1591 </SidebarLink>
1592 <SidebarLink to={`/${slug}/-/rules`} icon={<Scale size={15} />} current={at("rules")}>
1593 Rules
1594 </SidebarLink>
1595 </SidebarGroup>
1596 <Rule />
1597 <div className="space-y-px">
1598 <SidebarLink to={`/${slug}/-/audit`} icon={<History size={15} />} current={at("audit")}>
1599 Audit log
1600 </SidebarLink>
1601 <SidebarLink to={owner ? `/${slug}/-/settings` : `/${slug}/-/repositories`} icon={<Settings size={15} />} drill current={inSettings}>
1602 Settings
1603 </SidebarLink>
1604 </div>
1605 </nav>
1606 );
1607 const settings = (
1608 <nav aria-label="Workspace settings" className={PANEL}>
1609 <BackRow to={`/${slug}/-/workspace`} label="Settings" context={slug} />
1610 <div className="mt-2 space-y-px">
1611 {owner && (
1612 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
1613 General
1614 </SidebarLink>
1615 )}
1616 <SidebarLink to={`/${slug}/-/repositories`} icon={<BookMarked size={15} />}>
1617 Repositories
1618 </SidebarLink>
1619 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
1620 Access tokens
1621 </SidebarLink>
1622 {owner && (
1623 <SidebarLink to={`/${slug}/-/personal-access-tokens`} icon={<UserRoundKey size={15} />}>
1624 Personal access tokens
1625 </SidebarLink>
1626 )}
1627 <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
1628 Webhooks
1629 </SidebarLink>
1630 <SidebarLink to={`/${slug}/-/emoji`} icon={<Smile size={15} />}>
1631 Emoji
1632 </SidebarLink>
1633 </div>
1634 <SidebarGroup title="Runs" className="mt-3">
1635 <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}>
1636 Secrets and variables
1637 </SidebarLink>
1638 <SidebarLink to={`/${slug}/-/actions`} icon={<PlayCircle size={15} />}>
1639 Actions
1640 </SidebarLink>
1641 {owner && (
1642 <SidebarLink to={`/${slug}/-/runners`} icon={<ServerCog size={15} />}>
1643 Runners
1644 </SidebarLink>
1645 )}
1646 </SidebarGroup>
1647 </nav>
1648 );
1649 const trail: Level[] = [{ key: "workspace", node: main }];
1650 if (inSettings) trail.push({ key: "workspace:settings", node: settings });
1651 return (
1652 <div className="flex h-full flex-col">
1653 <ModeHeader title="Workspace" onClose={onClose} />
1654 <Drill trail={trail} />
1655 </div>
1656 );
1657}
1658
1659/** Your own settings, beside the rail: under the account menu, not any workspace's. */
1660export function AccountSidebar({ username, onClose }: { username: string; onClose?: () => void }) {
1661 return (
1662 <div className="flex h-full flex-col">
1663 <ModeHeader title="Your account" onClose={onClose} />
1664 <div className="min-h-0 grow">
1665 <AccountSettingsMenu username={username} />
1666 </div>
1667 </div>
1668 );
1669}
1670
1671
1672/** Words for the sections a path can end in. */
1673const SECTIONS: Record<string, string> = {
1674 issues: "Issues",
1675 milestones: "Milestones",
1676 labels: "Labels",
1677 pulls: "Pull requests",
1678 queue: "Merge queue",
1679 commits: "Commits",
1680 plans: "Outcomes",
1681 actions: "Workflows",
1682 soon: "Soon",
1683 deployments: "Deployments",
1684 repository: "Repository",
1685 access: "Access",
1686 invitations: "Invitation",
1687 repositories: "Repositories",
1688 branches: "Branches",
1689 tags: "Tags",
1690 compare: "Compare",
1691 dependencies: "Dependencies",
1692 code: "Files",
1693 secrets: "Secrets and variables",
1694 settings: "Settings",
1695 people: "People",
1696 projects: "Projects",
1697 teams: "Teams",
1698 tokens: "Access tokens",
1699 "personal-access-tokens": "Personal access tokens",
1700 usage: "Usage",
1701 gateway: "AI Gateway",
1702 billing: "Billing and plans",
1703 integrations: "Integrations",
1704 webhooks: "Webhooks",
1705 emoji: "Emoji",
1706 domains: "Domains",
1707 guardrails: "Guardrails",
1708 rules: "Rules",
1709 audit: "Audit log",
1710 tree: "Files",
1711 blob: "Files",
1712 agents: "Agents",
1713 context: "Context",
1714 memory: "Memory",
1715 security: "Security",
1716 packages: "Packages",
1717 runners: "Runners",
1718 environments: "Environments",
1719 workflows: "Workflows",
1720 observability: "Observability",
1721 insights: "Insights",
1722 sessions: "Sessions",
1723 chat: "Chat",
1724 docs: "Docs",
1725 home: "Home",
1726 "code-access": "Code access",
1727 overview: "Overview",
1728 workspace: "Workspace",
1729};
1730
1731/** Settings pages whose name differs from the section's of the same word. */
1732const SETTINGS_SECTIONS: Record<string, string> = { branches: "Branches and merging", actions: "Actions" };
1733
1734/** Where the page is, as a trail of links: workspace / repository / section. */
1735function Breadcrumbs({ pathname, missing, repo }: { pathname: string; missing?: boolean; repo?: ShellData["repo"] }) {
1736 // A 404 names nothing from the address, unless the project is real and
1737 // visible and only something inside it is missing.
1738 const visibleRepo = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(pathname);
1739 if (missing && !(visibleRepo && sameRepo(repo ?? null, { namespace: visibleRepo[1]!, name: visibleRepo[2]! }))) {
1740 return <span className="text-sm font-medium">Not found</span>;
1741 }
1742 const parts = pathname.split("/").filter(Boolean);
1743 const reserved = ["settings", "explore", "new", "search", "workspaces", "policies", "security", "support", "status", "invite", "inbox"];
1744 if (parts.length === 0) return <span className="text-sm font-medium">Home</span>;
1745 // Your settings: Settings / Emails.
1746 if (parts[0] === "settings") {
1747 const page = accountSettingsPage(pathname);
1748 return <Trail trail={[{ label: "Settings", to: FIRST_SETTINGS_PAGE }, ...(page ? [{ label: ACCOUNT_SETTINGS[page].title, to: `/settings/${page}` }] : [])]} />;
1749 }
1750 if (reserved.includes(parts[0]!)) {
1751 const words: Record<string, string> = {
1752 settings: "Account",
1753 explore: "Explore",
1754 new: "New project",
1755 search: "Search",
1756 workspaces: "New workspace",
1757 policies: "Policies",
1758 security: "Security",
1759 support: "Support",
1760 status: "Status",
1761 invite: "Invite",
1762 inbox: "Inbox",
1763 };
1764 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
1765 }
1766 // A person's profile, by their handle.
1767 if (parts[0] === "u" && parts[1]) {
1768 return <span className="truncate font-mono text-[0.8125rem] font-medium">@{parts[1]}</span>;
1769 }
1770 const [owner, second, third, fourth] = parts;
1771 const trail: Crumb[] = [{ label: owner!, to: `/${owner}`, mono: true }];
1772 if (second === "-") {
1773 const page = `/${owner}/-/${third}`;
1774 if (third && SETTINGS_PAGE.test(page)) {
1775 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
1776 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
1777 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
1778 } else if (second) {
1779 const repo = `/${owner}/${second}`;
1780 trail.push({ label: second, to: repo, mono: true });
1781 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, short: `#${fourth}`, to: `${repo}/pull/${fourth}` });
1782 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, short: `#${fourth}`, to: `${repo}/issues/${fourth}` });
1783 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
1784 else if (third && SECTIONS[third]) {
1785 trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
1786 // A settings page names which one: Settings / Secrets and variables.
1787 if (third === "settings" && fourth && SECTIONS[fourth]) {
1788 trail.push({ label: SETTINGS_SECTIONS[fourth] ?? SECTIONS[fourth]!, to: `${repo}/settings/${fourth}` });
1789 }
1790 }
1791 }
1792 return <Trail trail={trail} />;
1793}
1794
1795/** One link of the trail; `short` is what a phone shows when it is the page itself. */
1796type Crumb = { label: string; to: string; mono?: boolean; short?: string };
1797
1798/**
1799 * The trail of links. On a phone there is room for one: the page itself,
1800 * with a way back to where it sits; the whole trail from a wider screen up.
1801 */
1802function Trail({ trail }: { trail: Crumb[] }) {
1803 // Up on a phone, unless up is the workspace itself: its pages are the tabs.
1804 const up = trail.length > 1 && !/^\/[^/]+$/.test(trail[trail.length - 2]!.to) ? trail[trail.length - 2]! : null;
1805 return (
1806 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
1807 {up && (
1808 <Link
1809 to={up.to}
1810 prefetch="intent"
1811 aria-label={`Back to ${up.label}`}
1812 className="-ml-1 flex size-8 shrink-0 items-center justify-center rounded-md text-muted transition-colors hover:bg-raised hover:text-fg sm:hidden"
1813 >
1814 <ChevronLeft size={16} />
1815 </Link>
1816 )}
1817 {trail.map((crumb, index) => {
1818 const last = index === trail.length - 1;
1819 return (
1820 <span key={index} className={`min-w-0 items-center gap-1.5 ${last ? "flex" : "hidden sm:flex"}`}>
1821 {index > 0 && <span className="hidden text-line-strong sm:inline">/</span>}
1822 <Link
1823 to={crumb.to}
1824 prefetch="intent"
1825 aria-current={last ? "page" : undefined}
1826 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
1827 last ? "font-medium text-fg" : "text-muted hover:text-fg"
1828 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
1829 >
1830 {last && crumb.short ? (
1831 <>
1832 <span className="sm:hidden">{crumb.short}</span>
1833 <span className="hidden sm:inline">{crumb.label}</span>
1834 </>
1835 ) : (
1836 crumb.label
1837 )}
1838 </Link>
1839 </span>
1840 );
1841 })}
1842 </nav>
1843 );
1844}
1845
1846type Command = PaletteCommand;
1847
1848/** Everything the palette can jump to, from what the sidebar already knows. */
1849function commandsFor(user: User | null, shell: ShellData, here: string, signUpLabel = "Sign up"): Command[] {
1850 if (!user) return visitorCommands(shell, here, signUpLabel);
1851 const commands: Command[] = [
1852 { label: "Home", to: "/", icon: <House size={15} /> },
1853 ...(shell.workspace
1854 ? [
1855 { label: "Chat", to: `/${shell.workspace.slug}/-/chat`, icon: <MessagesSquare size={15} /> },
1856 { label: "Agents", to: `/${shell.workspace.slug}/-/agents`, icon: <Sparkles size={15} /> },
1857 ...(hasCodeAccess(shell.workspace) ? [{ label: "Code overview", to: `/${shell.workspace.slug}/-/overview`, icon: <Code2 size={15} /> }] : []),
1858 { label: "Workspace", hint: "People, billing, policies, settings", to: `/${shell.workspace.slug}/-/workspace`, icon: <Building2 size={15} /> },
1859 ]
1860 : []),
1861 ...(shell.repos.length > 0
1862 ? [{ label: "Put an agent on it", hint: "Open an issue and assign g1t", to: "/?agent=new", icon: <Sparkles size={15} /> }]
1863 : []),
1864 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
1865 { label: "Search g1t", hint: "Repositories, code, issues, people", to: "/search", icon: <Search size={15} /> },
1866 { label: "Inbox", hint: "What needs you", to: "/inbox", icon: <Bell size={15} /> },
1867 { label: "New project", to: "/new", icon: <Plus size={15} /> },
1868 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
1869 { label: "Your settings", to: FIRST_SETTINGS_PAGE, icon: <Settings size={15} /> },
1870 ...(Object.keys(ACCOUNT_SETTINGS) as AccountSettingsPage[])
1871 .filter((page) => page !== "profile")
1872 .map((page) => ({ label: ACCOUNT_SETTINGS[page].title, hint: "Your settings", to: `/settings/${page}`, icon: <Settings size={15} /> })),
1873 ];
1874 const repo = shell.repo;
1875 if (repo) {
1876 const base = `/${repo.namespace}/${repo.name}`;
1877 const name = `${repo.namespace}/${repo.name}`;
1878 commands.unshift(
1879 { label: "Overview", hint: name, to: base, icon: <LayoutGrid size={15} /> },
1880 { label: "Code", hint: name, to: `${base}/code`, icon: <Code2 size={15} /> },
1881 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
1882 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
1883 ...(repo.member
1884 ? [{ label: `Put an agent on ${repo.name}`, hint: name, to: `${base}/issues/new?agent=1`, icon: <Sparkles size={15} /> }]
1885 : []),
1886 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
1887 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
1888 ...(repo.member
1889 ? [
1890 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
1891 { label: "Deployments", hint: name, to: `${base}/deployments`, icon: <Rocket size={15} /> },
1892 { label: "Secrets and variables", hint: name, to: `${base}/settings/secrets`, icon: <Lock size={15} /> },
1893 { label: "Project settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
1894 { label: "Repository settings", hint: name, to: `${base}/settings/repository`, icon: <Settings size={15} /> },
1895 ]
1896 : []),
1897 );
1898 }
1899 for (const membership of user.workspaces ?? []) {
1900 commands.push(
1901 { label: displayName(membership), hint: `Workspace · ${membership.slug}`, to: `/${membership.slug}`, icon: <Avatar name={membership.slug} image={membership.avatar} size={15} square /> },
1902 { label: "All projects", hint: membership.slug, to: `/${membership.slug}/-/projects`, icon: <LayoutGrid size={15} /> },
1903 { label: "People", hint: membership.slug, to: `/${membership.slug}/-/people`, icon: <Users size={15} /> },
1904 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
1905 { label: "AI Gateway", hint: `${membership.slug} · Usage`, to: `/${membership.slug}/-/gateway`, icon: <Network size={15} /> },
1906 { label: "Billing and plans", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
1907 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
1908 { label: "Integrations", hint: membership.slug, to: `/${membership.slug}/-/integrations`, icon: <Plug size={15} /> },
1909 );
1910 }
1911 for (const listed of shell.repos) {
1912 commands.push({
1913 label: listed.title ?? listed.name,
1914 hint: `${listed.namespace} · Project`,
1915 to: `/${listed.namespace}/${listed.name}`,
1916 icon: <Box size={15} />,
1917 });
1918 }
1919 // "Put an agent on …": one per project, after the projects themselves.
1920 for (const listed of shell.repos) {
1921 if (repo && listed.namespace === repo.namespace && listed.name === repo.name) continue;
1922 commands.push({
1923 label: `Put an agent on ${listed.title ?? listed.name}`,
1924 hint: `${listed.namespace}/${listed.name}`,
1925 to: `/${listed.namespace}/${listed.name}/issues/new?agent=1`,
1926 icon: <Sparkles size={15} />,
1927 });
1928 }
1929 return commands;
1930}
1931
1932/** What the palette offers a visitor: browsing, the project they are in, and signing in. */
1933function visitorCommands(shell: ShellData, here: string, signUpLabel: string): Command[] {
1934 const commands: Command[] = [];
1935 const repo = shell.repo;
1936 if (repo) {
1937 const base = `/${repo.namespace}/${repo.name}`;
1938 const name = `${repo.namespace}/${repo.name}`;
1939 commands.push(
1940 { label: "Overview", hint: name, to: base, icon: <LayoutGrid size={15} /> },
1941 { label: "Code", hint: name, to: `${base}/code`, icon: <Code2 size={15} /> },
1942 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
1943 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
1944 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
1945 );
1946 }
1947 commands.push(
1948 { label: "Explore", hint: "Public projects", to: "/explore", icon: <Compass size={15} /> },
1949 { label: "Search g1t", hint: "Repositories, code, issues, people", to: "/search", icon: <Search size={15} /> },
1950 { label: "Pricing", to: "/pricing", icon: <CreditCard size={15} /> },
1951 { label: "Documentation", to: "https://docs.g1t.sh/", icon: <BookOpen size={15} /> },
1952 { label: "Sign in", to: withNext("/login", here), icon: <LogIn size={15} /> },
1953 { label: signUpLabel, to: withNext("/register", here), icon: <Plus size={15} /> },
1954 );
1955 return commands;
1956}
1957
1958/**
1959 * The page while the next one loads: after a moment it dims, under the
1960 * bar below, so it is plainly on its way out rather than frozen, and the
1961 * next page fades up from there. Sending a form does not dim it: the
1962 * form's button says it is working. Spread on the element holding the page.
1963 */
1964export function useLeaving() {
1965 const navigation = useNavigation();
1966 const method = navigation.formMethod?.toUpperCase();
1967 const leaving = navigation.state === "loading" && (method == null || method === "GET");
1968 return {
1969 "aria-busy": leaving || undefined,
1970 className: `transition-opacity motion-reduce:transition-none ${leaving ? "opacity-60 delay-200 duration-300" : "duration-150"}`,
1971 };
1972}
1973
1974/**
1975 * A bar across the top of the page while the next one loads, as GitHub
1976 * has: it appears at once, creeps towards the end while waiting, then fills
1977 * and fades when the page arrives.
1978 */
1979export function Progress() {
1980 const navigation = useNavigation();
1981 const busy = navigation.state !== "idle";
1982 const [width, setWidth] = useState(0);
1983 const [visible, setVisible] = useState(false);
1984 useEffect(() => {
1985 if (busy) {
1986 setVisible(true);
1987 setWidth(12);
1988 // Slows as it goes, never quite arriving until the page does.
1989 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
1990 return () => clearInterval(timer);
1991 }
1992 setWidth((now) => (now > 0 ? 100 : 0));
1993 const fade = setTimeout(() => setVisible(false), 250);
1994 const reset = setTimeout(() => setWidth(0), 550);
1995 return () => {
1996 clearTimeout(fade);
1997 clearTimeout(reset);
1998 };
1999 }, [busy]);
2000 return (
2001 <div
2002 aria-hidden="true"
2003 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
2004 visible ? "opacity-100" : "opacity-0"
2005 }`}
2006 >
2007 <div
2008 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
2009 style={{ width: `${width}%` }}
2010 />
2011 </div>
2012 );
2013}
2014
2015/** The top bar's way to g1t: the direct message with the workspace's orchestrator, opened or made. */
2016function AskG1tButton({ slug }: { slug: string }) {
2017 return (
2018 <Hint label="Ask g1t">
2019 <Link
2020 to={`/${slug}/-/chat?agent=g1t`}
2021 aria-label="Ask g1t"
2022 className="flex h-9 items-center gap-1.5 rounded-md border border-line px-2 text-sm text-fg/90 transition-colors hover:border-line-strong hover:bg-raised hover:text-fg sm:px-2.5"
2023 >
2024 <G1tMark size={18} />
2025 <span className="hidden sm:inline">Ask g1t</span>
2026 </Link>
2027 </Hint>
2028 );
2029}
2030
2031/** The mode's sidebar beside the rail: a shade lighter than the page. */
2032const SIDEBAR_BOX = "h-full border-r border-line bg-[color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))]";
2033
2034/**
2035 * Which sidebar sits beside the rail: each mode's own, or none (Docs, and
2036 * the Inbox, which is a page of its own). Without a workspace (a visitor),
2037 * the one sidebar there always was.
2038 */
2039function sidebarFor(mode: ModeKey | null): Panel | null {
2040 if (mode == null) return "code";
2041 if (mode === "docs" || mode === "inbox") return null;
2042 return mode;
2043}
2044
2045/** The sidebars that sit beside the rail, one per mode that has one. */
2046type Panel = "home" | "chat" | "agents" | "code" | "workspace" | "account";
2047
2048/**
2049 * The app: a sidebar with the workspace, its repositories and the sections
2050 * of the one being looked at; a slim bar with search and the account; and
2051 * the page. A visitor who is not signed in gets the same frame, with
2052 * Explore and Search in place of the workspace, and signing in in place of
2053 * the account.
2054 */
2055export function AppShell({
2056 user,
2057 shell,
2058 missing = false,
2059 banner,
2060 children,
2061}: {
2062 user: User | null;
2063 shell: ShellData;
2064 /** The page is a 404 (see Sidebar). */
2065 missing?: boolean;
2066 banner?: ReactNode;
2067 children: ReactNode;
2068}) {
2069 const { pathname, search } = useLocation();
2070 const [drawer, setDrawer] = useState(false);
2071 const [palette, setPalette] = useState(false);
2072 const here = pathname + search;
2073 const signUpLabel = useSignUpCopy().primary;
2074 const commands = useMemo(() => commandsFor(user, shell, here, signUpLabel), [user, shell, here, signUpLabel]);
2075
2076 // A new page closes the drawer on small screens.
2077 useEffect(() => setDrawer(false), [pathname]);
2078 // Mission control's code, fetched while the browser is idle, so going
2079 // home never waits on it (routes/home.tsx).
2080 useEffect(() => {
2081 if (!user) return;
2082 const load = () => void import("./mission-control").catch(() => undefined);
2083 const idle = (window as { requestIdleCallback?: (callback: () => void) => number }).requestIdleCallback;
2084 if (idle) idle(load);
2085 else setTimeout(load, 1500);
2086 }, [user]);
2087 usePaletteShortcut(() => setPalette((open) => !open));
2088 const leaving = useLeaving();
2089 useVisualViewport();
2090
2091 // The rail and the mode's sidebar (docs/WORKSPACE.md, "Shell"), for
2092 // someone in a workspace. A visitor, or someone with none, keeps the one
2093 // sidebar.
2094 const going = useNavigation().location?.pathname;
2095 const ws = user ? shell.workspace : null;
2096 const mode: ModeKey | null = ws ? modeOf(going ?? pathname, ws.slug) : null;
2097 const panel = ws ? sidebarFor(mode) : "code";
2098 const code = ws ? hasCodeAccess(ws) : true;
2099 const { sidebar: chatSidebar } = useChatSidebar();
2100 // Notify: the feed socket's live counts (lib/notify-client.ts) once it has them; the page's until then.
2101 const live = useLiveBadges(ws?.slug);
2102 const chatUnread = chatSidebar
2103 ? unreadTotals(chatSidebar.entries)
2104 : live?.chat != null
2105 ? { unread: live.chat, mentions: live.mentions ?? 0 }
2106 : (shell.chat ?? { unread: 0, mentions: 0 });
2107 const inboxUnread = live?.inbox ?? shell.inbox?.unread ?? 0;
2108 const sidebarNode = (inSheet: boolean) => {
2109 const close = inSheet ? () => setDrawer(false) : undefined;
2110 const find = () => {
2111 if (inSheet) setDrawer(false);
2112 setPalette(true);
2113 };
2114 if (!ws || !user || panel === "code") {
2115 return <Sidebar user={user} shell={shell} missing={missing} onFind={find} onClose={close} rail={Boolean(ws && user)} />;
2116 }
2117 switch (panel) {
2118 case "home":
2119 return <HomeSidebar slug={ws.slug} shell={shell} code={code} onFind={find} header={<ModeHeader title="Home" onClose={close} />} />;
2120 case "chat":
2121 return <ChatSidebar slug={ws.slug} />;
2122 case "agents":
2123 return <AgentsSidebar slug={ws.slug} shellAgents={shell.agents ?? null} code={code} owner={ws.role === "owner"} />;
2124 case "workspace":
2125 return <WorkspaceSidebar slug={ws.slug} owner={ws.role === "owner"} onClose={close} />;
2126 case "account":
2127 return <AccountSidebar username={user.username} onClose={close} />;
2128 default:
2129 return null;
2130 }
2131 };
2132 const rail =
2133 user && ws ? (
2134 <Rail
2135 user={user}
2136 workspace={ws}
2137 unread={{ inbox: inboxUnread, chat: chatUnread.unread, mentions: chatUnread.mentions }}
2138 help={<HelpMenu />}
2139 account={<AccountMenu user={user} rail />}
2140 />
2141 ) : null;
2142 const pad = rail ? (panel ? "lg:pl-[21rem]" : "lg:pl-20") : "lg:pl-64";
2143 // On a phone (below 768px): the tab bar, and a conversation full screen.
2144 const conversation = isConversation(going ?? pathname);
2145 const tabs = Boolean(user && ws) && !conversation;
2146
2147 return (
2148 // The phone's menu is a sheet: a dialog that holds focus, closes on
2149 // Escape or a tap outside, and gives focus back to the menu button.
2150 <Sheet open={drawer} onOpenChange={setDrawer}>
2151 <div className="min-h-dvh">
2152 <Progress />
2153 <aside className="fixed inset-y-0 left-0 z-40 hidden lg:flex">
2154 {rail}
2155 {panel && <div className={`${SIDEBAR_BOX} w-64`}>{sidebarNode(false)}</div>}
2156 </aside>
2157 <SheetContent
2158 side="left"
2159 showClose={false}
2160 aria-describedby={undefined}
2161 // Focus would land on the workspace's avatar and open its hint over
2162 // the sidebar's heading; the sheet itself takes it instead.
2163 onOpenAutoFocus={(event) => {
2164 event.preventDefault();
2165 (event.currentTarget as HTMLElement | null)?.focus();
2166 }}
2167 className={`lg:hidden ${rail ? `flex flex-row ${panel ? "w-[21rem] max-w-[92vw] sm:max-w-[21rem]" : "w-20 sm:max-w-20"}` : "w-72 max-w-[85vw] sm:max-w-72"}`}
2168 >
2169 <SheetTitle className="sr-only">Menu</SheetTitle>
2170 {rail}
2171 {panel && <div className={`min-w-0 grow ${rail ? SIDEBAR_BOX : ""}`}>{sidebarNode(true)}</div>}
2172 </SheetContent>
2173
2174 <div className={`flex min-h-dvh min-w-0 flex-col ${pad} ${tabs ? "pb-(--tabbar-h)" : ""}`}>
2175 <header
2176 className={`sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-line bg-bg/85 pt-[env(safe-area-inset-top)] pr-[max(1rem,env(safe-area-inset-right))] pl-[max(1rem,env(safe-area-inset-left))] backdrop-blur sm:px-6 max-md:h-[calc(3.5rem+env(safe-area-inset-top))] ${mode === "chat" ? "lg:hidden" : ""} ${conversation ? "max-md:hidden" : ""}`}
2177 >
2178 {/* A phone: the workspace's avatar opens everything else (components/mobile.tsx). */}
2179 {user && ws && <AvatarSheetButton user={user} workspace={ws} />}
2180 <SheetTrigger
2181 aria-label="Open menu"
2182 className={`rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden ${user && ws ? "max-md:hidden" : ""}`}
2183 >
2184 <Menu size={18} />
2185 </SheetTrigger>
2186 <Breadcrumbs pathname={pathname} missing={missing} repo={shell.repo} />
2187 <div className="ml-auto flex items-center gap-1.5">
2188 {/* Search lives in the sidebar ("Search or jump to"); with the sidebar folded away, this opens the same palette. */}
2189 <button
2190 type="button"
2191 aria-label="Search or jump to"
2192 onClick={() => setPalette(true)}
2193 className="flex size-9 items-center justify-center rounded-md text-muted transition-colors hover:bg-raised hover:text-fg lg:hidden"
2194 >
2195 <Search size={16} />
2196 </button>
2197 {/* All of g1t's public projects: not any one workspace's, so here, not in the sidebar. */}
2198 <NavLink
2199 to="/explore"
2200 prefetch="intent"
2201 aria-label="Explore"
2202 className={({ isActive }) =>
2203 `flex h-9 items-center gap-1.5 rounded-md px-2 text-sm transition-colors hover:bg-raised hover:text-fg sm:px-2.5 ${user ? "max-md:hidden" : ""} ${isActive ? "text-fg" : "text-muted"}`
2204 }
2205 >
2206 <Compass size={16} className="sm:hidden" />
2207 <span className="hidden sm:inline">Explore</span>
2208 </NavLink>
2209 <a
2210 href="https://docs.g1t.sh/"
2211 className="hidden rounded-md px-2.5 py-1.5 text-sm text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
2212 >
2213 Docs
2214 </a>
2215 {user && (
2216 <>
2217 {shell.workspace ? <AskG1tButton slug={shell.workspace.slug} /> : <AgentButton />}
2218 {/* On a phone the Inbox is a tab. */}
2219 <span className={shell.workspace ? "max-md:hidden" : undefined}>
2220 <InboxBell counts={shell.inbox ? { ...shell.inbox, unread: inboxUnread } : null} />
2221 </span>
2222 </>
2223 )}
2224 {!user ? (
2225 // The sidebar has these too, but on a phone it is folded away.
2226 <Link
2227 to={withNext("/login", here)}
2228 className="flex h-9 items-center rounded-md bg-fg px-3 text-sm font-medium text-bg transition-colors hover:bg-white lg:hidden"
2229 >
2230 Sign in
2231 </Link>
2232 ) : (
2233 <>
2234 {/* Room between what comes in (the inbox) and what you make. */}
2235 <span aria-hidden="true" className="mx-1 h-5 w-px bg-line" />
2236 <DropdownMenu>
2237 <Tooltip>
2238 <TooltipTrigger asChild>
2239 <DropdownMenuTrigger
2240 aria-label="Create new"
2241 className="flex h-9 items-center gap-1 rounded-md border border-line px-2 text-fg/90 outline-none transition-colors hover:border-line-strong hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:bg-raised"
2242 >
2243 <Plus size={16} />
2244 <ChevronDown size={13} className="text-muted" />
2245 </DropdownMenuTrigger>
2246 </TooltipTrigger>
2247 <TooltipContent>Create new…</TooltipContent>
2248 </Tooltip>
2249 <DropdownMenuContent align="end" className="min-w-52">
2250 {shell.repo && (
2251 <>
2252 <DropdownMenuLabel className="truncate">In {shell.repo.name}</DropdownMenuLabel>
2253 <DropdownMenuItem asChild>
2254 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
2255 <CircleDot />
2256 New issue
2257 </Link>
2258 </DropdownMenuItem>
2259 {shell.repo.member && (
2260 <DropdownMenuItem asChild>
2261 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
2262 <ListTree />
2263 Plan work
2264 </Link>
2265 </DropdownMenuItem>
2266 )}
2267 <DropdownMenuSeparator />
2268 </>
2269 )}
2270 <DropdownMenuItem asChild>
2271 <Link to="/?agent=new">
2272 <Sparkles />
2273 Put an agent on it
2274 </Link>
2275 </DropdownMenuItem>
2276 <DropdownMenuItem asChild>
2277 <Link to="/new">
2278 <Box />
2279 New project
2280 </Link>
2281 </DropdownMenuItem>
2282 <DropdownMenuItem asChild>
2283 <Link to="/new/github">
2284 <GitBranch />
2285 Import from GitHub
2286 </Link>
2287 </DropdownMenuItem>
2288 <DropdownMenuSeparator />
2289 <DropdownMenuItem asChild>
2290 <Link to="/workspaces/new">
2291 <Users />
2292 New workspace
2293 </Link>
2294 </DropdownMenuItem>
2295 {/* A team in the workspace the sidebar is about, for whoever it lets create one. */}
2296 {shell.workspace &&
2297 (mayCreateTeams(shell.workspace.team_creation, shell.workspace.role) ? (
2298 <DropdownMenuItem asChild>
2299 <Link to={`/${shell.workspace.slug}/-/teams/new`}>
2300 <UsersRound />
2301 New team
2302 </Link>
2303 </DropdownMenuItem>
2304 ) : (
2305 <DropdownMenuItem disabled title="Only owners can create teams in this workspace.">
2306 <UsersRound />
2307 New team
2308 <span className="ml-auto pl-3 text-xs text-faint">Owners only</span>
2309 </DropdownMenuItem>
2310 ))}
2311 </DropdownMenuContent>
2312 </DropdownMenu>
2313 </>
2314 )}
2315 </div>
2316 </header>
2317 {banner}
2318 <main id="content" tabIndex={-1} {...leaving} className={`min-w-0 grow outline-none ${leaving.className}`}>
2319 <InMain.Provider value={true}>{children}</InMain.Provider>
2320 </main>
2321 </div>
2322 {user && ws && <MobileTabBar workspace={ws} unread={{ inbox: inboxUnread, chat: chatUnread.unread, mentions: chatUnread.mentions }} />}
2323 <CommandPalette
2324 open={palette}
2325 onOpenChange={setPalette}
2326 commands={commands}
2327 repo={shell.repo ? `${shell.repo.namespace}/${shell.repo.name}` : null}
2328 />
2329 </div>
2330 </Sheet>
2331 );
2332}