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