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