Skip to content
2,381 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 <SidebarLink to={`${base}/settings/dependencies`} icon={<Network size={15} />}>
1121 Dependencies
1122 </SidebarLink>
1123 </div>
1124 </>
1125 )}
1126 {agents && (
1127 <>
1128 <Rule />
1129 <div className="space-y-px">
1130 {shows("agents") && (
1131 <SidebarLink to={`${base}/settings/agents`} icon={<Bot size={15} />}>
1132 Agents
1133 </SidebarLink>
1134 )}
1135 {shows("guardrails") && (
1136 <SidebarLink to={`${base}/settings/guardrails`} icon={<ShieldCheck size={15} />}>
1137 Guardrails
1138 </SidebarLink>
1139 )}
1140 </div>
1141 </>
1142 )}
1143 <Rule />
1144 <div className="space-y-px">
1145 {shows("repository") && (
1146 <SidebarLink to={`${base}/settings/repository`} icon={<BookMarked size={15} />}>
1147 Repository
1148 </SidebarLink>
1149 )}
1150 {shows("access") && (
1151 <SidebarLink to={`${base}/settings/access`} icon={<Users size={15} />}>
1152 Access
1153 </SidebarLink>
1154 )}
1155 {shows("keys") && (
1156 <SidebarLink to={`${base}/settings/keys`} icon={<KeyRound size={15} />}>
1157 Deploy keys
1158 </SidebarLink>
1159 )}
1160 {shows("branches") && (
1161 <SidebarLink to={`${base}/settings/branches`} icon={<GitBranch size={15} />}>
1162 Branches and merging
1163 </SidebarLink>
1164 )}
1165 {shows("rules") && (
1166 <SidebarLink to={`${base}/settings/rules`} icon={<Scale size={15} />}>
1167 Rules
1168 </SidebarLink>
1169 )}
1170 {shows("secrets") && (
1171 <SidebarLink to={`${base}/settings/secrets`} icon={<Lock size={15} />}>
1172 Secrets and variables
1173 </SidebarLink>
1174 )}
1175 {shows("actions") && (
1176 <SidebarLink to={`${base}/settings/actions`} icon={<PlayCircle size={15} />}>
1177 Actions
1178 </SidebarLink>
1179 )}
1180 {shows("environments") && (
1181 <SidebarLink to={`${base}/settings/environments`} icon={<Layers size={15} />}>
1182 Environments
1183 </SidebarLink>
1184 )}
1185 {shows("runners") && (
1186 <SidebarLink to={`${base}/settings/runners`} icon={<ServerCog size={15} />}>
1187 Runners
1188 </SidebarLink>
1189 )}
1190 {shows("webhooks") && (
1191 <SidebarLink to={`${base}/settings/webhooks`} icon={<Webhook size={15} />}>
1192 Webhooks
1193 </SidebarLink>
1194 )}
1195 </div>
1196 </nav>
1197 );
1198}
1199
1200/**
1201 * Whether the menus list Settings → Invites (invites to g1t): always while
1202 * sign-up takes an invite; once anyone can sign up, only for someone with
1203 * invites already made to look back on, which the account menu's data says.
1204 */
1205function useInvitesListed(): boolean {
1206 const inviteOnly = useInviteOnly();
1207 const details = useFetcher<AccountMenuData | null>({ key: "account-menu" });
1208 useEffect(() => {
1209 if (!inviteOnly && details.state === "idle" && details.data === undefined) details.load("/settings/menu.json");
1210 // eslint-disable-next-line react-hooks/exhaustive-deps
1211 }, [inviteOnly]);
1212 return inviteOnly || details.data?.invites_page === true;
1213}
1214
1215/** Your own settings, drilled into from Your settings: one page each. */
1216function AccountSettingsMenu({ username }: { username: string }) {
1217 const invites = useInvitesListed();
1218 const link = (page: AccountSettingsPage, icon: ReactNode) => (
1219 <SidebarLink to={`/settings/${page}`} icon={icon}>
1220 {ACCOUNT_SETTINGS[page].title}
1221 </SidebarLink>
1222 );
1223 return (
1224 <nav aria-label="Your settings" className={PANEL}>
1225 <BackRow to="/" label="Your settings" context={username} />
1226 <div className="mt-2 space-y-px">
1227 {link("profile", <CircleUserRound size={15} />)}
1228 {link("emails", <Mail size={15} />)}
1229 {invites && link("invites", <Ticket size={15} />)}
1230 </div>
1231 <Rule />
1232 <div className="space-y-px">
1233 {link("keys", <Fingerprint size={15} />)}
1234 {link("tokens", <KeyRound size={15} />)}
1235 </div>
1236 <Rule />
1237 <div className="space-y-px">
1238 {link("integrations", <Blocks size={15} />)}
1239 {link("github", <GithubMark className="size-[15px]" />)}
1240 {link("applications", <Plug size={15} />)}
1241 </div>
1242 <Rule />
1243 <div className="space-y-px">
1244 {link("two-factor", <ShieldCheck size={15} />)}
1245 {link("security-log", <History size={15} />)}
1246 </div>
1247 </nav>
1248 );
1249}
1250
1251/**
1252 * Signing in and signing up, in place of the account for a visitor. Signing
1253 * in brings them back to the page they are on.
1254 */
1255/** Help, status and the fine print under a visitor's sign-in buttons. */
1256function VisitorLinks() {
1257 const status = useSiteStatus();
1258 const quiet = `${LEGAL_LINK} hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/60`;
1259 const dot = (
1260 <span aria-hidden="true" className="text-[0.625rem] text-line-strong">
1261 ·
1262 </span>
1263 );
1264 return (
1265 // Two short rows, so a narrow sidebar never starts a line with a dot.
1266 <nav aria-label="About g1t" className="pt-0.5">
1267 <div className="flex items-center gap-0.5">
1268 <a href={STATUS_URL} className={`${quiet} gap-1.5`}>
1269 <StatusDot state={status?.overall.state ?? null} />
1270 Status
1271 </a>
1272 {dot}
1273 <Link to="/support" className={quiet}>
1274 Support
1275 </Link>
1276 </div>
1277 <div className="flex items-center gap-0.5">
1278 {LEGAL_LINKS.map(([label, to], index) => (
1279 <span key={to} className="flex items-center gap-0.5">
1280 {index > 0 && dot}
1281 <Link to={to} className={quiet}>
1282 {label}
1283 </Link>
1284 </span>
1285 ))}
1286 </div>
1287 </nav>
1288 );
1289}
1290
1291function VisitorPanel() {
1292 const { pathname, search } = useLocation();
1293 const signUp = useSignUpCopy();
1294 return (
1295 <div className="space-y-2">
1296 <p className="px-1 text-xs text-muted">Sign in to open issues, review pull requests and run agents.</p>
1297 <div className="grid grid-cols-2 gap-2">
1298 <Link
1299 to={withNext("/login", pathname + search)}
1300 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"
1301 >
1302 <LogIn size={14} />
1303 Sign in
1304 </Link>
1305 <Link
1306 to={withNext("/register", pathname + search)}
1307 className="flex h-9 items-center justify-center rounded-md bg-fg text-[0.8125rem] font-medium text-bg transition-colors hover:bg-white"
1308 >
1309 {signUp.primary}
1310 </Link>
1311 </div>
1312 <VisitorLinks />
1313 </div>
1314 );
1315}
1316
1317function Sidebar({
1318 user,
1319 shell,
1320 missing = false,
1321 onFind,
1322 onClose,
1323 rail = false,
1324}: {
1325 user: User | null;
1326 shell: ShellData;
1327 /** The page is a 404: the address names nothing the viewer can see. */
1328 missing?: boolean;
1329 onFind: () => void;
1330 /** In the sheet on a small screen: closing it, at the end of the top row. */
1331 onClose?: () => void;
1332 /**
1333 * Beside the rail, as Code's sidebar: code only, under a Code heading.
1334 * The workspace, its settings and the account are the rail's.
1335 */
1336 rail?: boolean;
1337}) {
1338 const ws = shell.workspace;
1339 const { pathname } = useLocation();
1340 const going = useNavigation().location?.pathname;
1341 // Where the sidebar is drilled to follows the page being gone to, so it
1342 // moves as the link is followed, not once the page arrives.
1343 const target = going ?? pathname;
1344 // On a 404, the address is not a place: no list is drilled into from it,
1345 // except the project's own when the project is real and visible and only
1346 // something inside it is missing (a file, a commit, an issue).
1347 const lost = missing && going == null;
1348 const inSettings = !lost && ws != null && SETTINGS_PAGE.exec(target)?.[1]?.toLowerCase() === ws.slug;
1349 const inAccount = !lost && target === "/settings" || target.startsWith("/settings/");
1350 const active = shell.repo;
1351 // In a repository, or on the way into one, its own list.
1352 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
1353 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"]);
1354 // An invitation to a repository is answered before its menu means anything.
1355 const inRepo =
1356 repoPath != null &&
1357 !reserved.has(repoPath[1]) &&
1358 repoPath[2] !== "-" &&
1359 !/^\/[^/]+\/[^/]+\/invitations\/?$/.test(target) &&
1360 (!lost || sameRepo(active, { namespace: repoPath[1]!, name: repoPath[2]! }));
1361 // A project's settings, one level further in.
1362 const inRepoSettings = inRepo && REPO_SETTINGS_PAGE.test(target);
1363 // The repository the menu is for: the one loaded if it is the one being
1364 // gone to, else what the address says, at once.
1365 const targetRepo = inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null;
1366 const guessed: MenuRepo | null = targetRepo
1367 ? sameRepo(active, targetRepo)
1368 ? active
1369 : {
1370 ...targetRepo,
1371 member: (user?.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()),
1372 }
1373 : null;
1374 // The viewer's role there, from the repository's page once it has loaded.
1375 const page = useRouteLoaderData("routes/repo/layout") as
1376 | { repo?: { namespace: string; name: string }; access?: ViewerAccess }
1377 | undefined;
1378 const menuRepo: MenuRepo | null =
1379 guessed && page?.access && page.repo && sameRepo(page.repo, guessed)
1380 ? { ...guessed, member: page.access.insider, settings: seesSettings(page.access), can: page.access.can }
1381 : guessed;
1382
1383 // The way from the main list to the one shown.
1384 const trail: Level[] = [{ key: "main", node: rail && user && ws ? <CodeMenu shell={shell} slug={ws.slug} /> : <MainMenu user={user} shell={shell} /> }];
1385 if (rail && !menuRepo) {
1386 // Code's own list: nothing to drill into but a project.
1387 } else if (user && inAccount && !rail) {
1388 trail.push({ key: "account", node: <AccountSettingsMenu username={user.username} /> });
1389 } else if (ws && inSettings && !rail) {
1390 trail.push({ key: `settings:${ws.slug}`, node: <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} /> });
1391 } else if (menuRepo) {
1392 const key = `repo:${menuRepo.namespace}/${menuRepo.name}`.toLowerCase();
1393 // Out of a project: to its workspace's projects when they are yours.
1394 const home = (user?.workspaces ?? []).find((m) => m.slug === menuRepo.namespace.toLowerCase());
1395 const back = !user
1396 ? { to: "/explore", label: "Explore" }
1397 : home
1398 ? { to: `/${home.slug}/-/projects`, label: "All projects" }
1399 : { to: "/", label: "Home" };
1400 trail.push({
1401 key,
1402 node: (
1403 <RepoMenu
1404 repo={menuRepo}
1405 isPrivate={[...shell.repos, ...(shell.shared ?? [])].some((repo) => sameRepo(repo, menuRepo) && repo.isPrivate)}
1406 back={back}
1407 />
1408 ),
1409 });
1410 if (inRepoSettings) trail.push({ key: `${key}:settings`, node: <RepoSettingsMenu repo={menuRepo} /> });
1411 }
1412
1413 return (
1414 <div className="flex h-full flex-col">
1415 {/* The same height and rule as the top bar, so the two read as one line. */}
1416 {rail ? (
1417 <ModeHeader title="Code" onClose={onClose} />
1418 ) : (
1419 <div className="flex h-14 shrink-0 items-center gap-1 border-b border-line pr-2 pl-2.5">
1420 {user ? (
1421 <>
1422 {/* The 1 alone beside the workspace: a square hover the height of the
1423 switcher, the mark as tall as the workspace avatar. */}
1424 <Link to="/" aria-label="g1t home" className="flex size-9 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-raised">
1425 <Mark tight className="h-4 w-auto" />
1426 </Link>
1427 <span className="shrink-0 text-line-strong" aria-hidden="true">
1428 /
1429 </span>
1430 <WorkspaceSwitcher user={user} shell={shell} />
1431 </>
1432 ) : (
1433 <Link to="/" aria-label="g1t home" className="mr-auto flex h-9 items-center rounded-md px-2 transition-colors hover:bg-raised">
1434 <Logo className="text-[1.25rem]" />
1435 </Link>
1436 )}
1437 {onClose && (
1438 <button
1439 type="button"
1440 aria-label="Close menu"
1441 onClick={onClose}
1442 className="flex size-9 shrink-0 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg"
1443 >
1444 <X size={16} />
1445 </button>
1446 )}
1447 </div>
1448 )}
1449 <div className="px-2 pt-3">
1450 <button
1451 type="button"
1452 onClick={onFind}
1453 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"
1454 >
1455 <Search size={14} />
1456 <span className="grow text-left">Search or jump to…</span>
1457 <PaletteKey className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line" />
1458 </button>
1459 </div>
1460 <Drill trail={trail} />
1461 {/* The account, or signing in, as one row at the very bottom; beside the rail, the rail has it. */}
1462 {!rail && (
1463 <div className="shrink-0 border-t border-line p-2">
1464 {user ? <AccountMenu user={user} /> : <VisitorPanel />}
1465 </div>
1466 )}
1467 </div>
1468 );
1469}
1470
1471/** A mode's sidebar heading: the same height and rule as the top bar, with a way to close the phone's sheet. */
1472export function ModeHeader({ title, action, onClose }: { title: ReactNode; action?: ReactNode; onClose?: () => void }) {
1473 return (
1474 <div className="flex h-14 shrink-0 items-center gap-1 border-b border-line pr-2.5 pl-4">
1475 <h2 className="min-w-0 grow truncate text-[0.9375rem] font-semibold">{title}</h2>
1476 {action}
1477 {onClose && (
1478 <button
1479 type="button"
1480 aria-label="Close menu"
1481 onClick={onClose}
1482 className="flex size-8 shrink-0 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg"
1483 >
1484 <X size={16} />
1485 </button>
1486 )}
1487 </div>
1488 );
1489}
1490
1491/**
1492 * Code's sidebar (beside the rail): only code. Its Overview (Mission
1493 * control's code panels), the projects, what spans them, and what is
1494 * coming. The workspace's people, money and settings are Workspace's;
1495 * agents, context and memory are Agents'.
1496 */
1497function CodeMenu({ shell, slug }: { shell: ShellData; slug: string }) {
1498 const { pathname } = useLocation();
1499 const going = useNavigation().location?.pathname;
1500 const path = going ?? pathname;
1501 const at = (page: string) => path === `/${slug}/-/${page}` || path.startsWith(`/${slug}/-/${page}/`);
1502 const shared = shell.shared ?? [];
1503 return (
1504 <nav aria-label="Code" className={PANEL}>
1505 <div className="mt-3 space-y-px">
1506 <SidebarLink to={`/${slug}/-/overview`} icon={<LayoutDashboard size={15} />} current={at("overview")}>
1507 Overview
1508 </SidebarLink>
1509 </div>
1510 <div className="mt-3">
1511 <SidebarProjects slug={slug} shell={shell} current={at("projects")} />
1512 </div>
1513 <Rule />
1514 <div className="space-y-px">
1515 <SidebarLink to={`/${slug}/-/security`} icon={<ShieldCheck size={15} />} current={at("security") && !at("security/settings")}>
1516 Security
1517 </SidebarLink>
1518 <SidebarLink to={`/${slug}/-/packages`} icon={<Package size={15} />} current={at("packages")}>
1519 Packages
1520 </SidebarLink>
1521 {INSIGHTS && (
1522 <SidebarSoonLink to={`/${slug}/-/insights`} icon={<TrendingUp size={15} />} about={INSIGHTS.summary} current={at("insights")}>
1523 {INSIGHTS.title}
1524 </SidebarSoonLink>
1525 )}
1526 {roadmapIn("Workspace")
1527 .filter((item) => item.key !== "insights" && item.key !== "teams" && item.key !== "fleet")
1528 .map((item) => (
1529 <SidebarSoonLink key={item.key} to={`/${slug}/-/soon/${item.key}`} icon={WORKSPACE_ICONS[item.key] ?? <Sparkles size={15} />} about={item.summary}>
1530 {item.title === "Board" ? "Boards" : item.title}
1531 </SidebarSoonLink>
1532 ))}
1533 </div>
1534 {shared.length > 0 && (
1535 <>
1536 <Rule />
1537 <SidebarGroup title="Shared with you">
1538 {shared.map((repo) => (
1539 <SidebarLink
1540 key={`${repo.namespace}/${repo.name}`}
1541 to={`/${repo.namespace}/${repo.name}`}
1542 icon={repo.isPrivate ? <Lock size={15} /> : <Box size={15} />}
1543 drill="hover"
1544 >
1545 <span className="font-mono text-faint">{repo.namespace}/</span>
1546 {repo.name}
1547 </SidebarLink>
1548 ))}
1549 </SidebarGroup>
1550 </>
1551 )}
1552 </nav>
1553 );
1554}
1555
1556/** Workspace pages that sit in its Settings list, drilled into from the Workspace sidebar. */
1557const WORKSPACE_SETTINGS = ["settings", "repositories", "tokens", "personal-access-tokens", "secrets", "actions", "runners", "webhooks", "emoji"];
1558
1559/**
1560 * The Workspace mode's sidebar: the workspace itself, for every member.
1561 * Its overview, people, money, connections, policies and record, then its
1562 * settings as a list of their own. Owner-only pages stay owner-only.
1563 */
1564export function WorkspaceSidebar({ slug, owner, onClose }: { slug: string; owner: boolean; onClose?: () => void }) {
1565 const { pathname } = useLocation();
1566 const going = useNavigation().location?.pathname;
1567 const path = going ?? pathname;
1568 const page = path.startsWith(`/${slug}/-/`) ? path.slice(`/${slug}/-/`.length) : "";
1569 const top = page.split("/")[0] ?? "";
1570 const at = (...pages: string[]) => pages.some((p) => page === p || page.startsWith(`${p}/`));
1571 const inSettings = WORKSPACE_SETTINGS.includes(top);
1572 const main = (
1573 <nav aria-label="Workspace" className={PANEL}>
1574 <div className="mt-3 space-y-px">
1575 <SidebarLink to={`/${slug}/-/workspace`} icon={<LayoutGrid size={15} />} current={at("workspace")}>
1576 Overview
1577 </SidebarLink>
1578 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />} current={at("people")}>
1579 People
1580 </SidebarLink>
1581 <SidebarLink to={`/${slug}/-/teams`} icon={<UsersRound size={15} />} current={at("teams")}>
1582 Teams
1583 </SidebarLink>
1584 </div>
1585 <SidebarGroup title="Usage and billing" className="mt-3">
1586 <SidebarLink to={`/${slug}/-/usage`} icon={<BarChart3 size={15} />} current={at("usage", "gateway")}>
1587 Usage
1588 </SidebarLink>
1589 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />} current={at("billing")}>
1590 Billing and plans
1591 </SidebarLink>
1592 </SidebarGroup>
1593 <SidebarGroup title="Connections" className="mt-3">
1594 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />} current={at("integrations")}>
1595 Integrations
1596 </SidebarLink>
1597 </SidebarGroup>
1598 <SidebarGroup title="Security policies" className="mt-3">
1599 <SidebarLink to={`/${slug}/-/security/settings`} icon={<ShieldCheck size={15} />} current={at("security/settings")}>
1600 Security settings
1601 </SidebarLink>
1602 <SidebarLink to={`/${slug}/-/guardrails`} icon={<Gauge size={15} />} current={at("guardrails")}>
1603 Guardrails
1604 </SidebarLink>
1605 <SidebarLink to={`/${slug}/-/rules`} icon={<Scale size={15} />} current={at("rules")}>
1606 Rules
1607 </SidebarLink>
1608 </SidebarGroup>
1609 <Rule />
1610 <div className="space-y-px">
1611 <SidebarLink to={`/${slug}/-/audit`} icon={<History size={15} />} current={at("audit")}>
1612 Audit log
1613 </SidebarLink>
1614 <SidebarLink to={owner ? `/${slug}/-/settings` : `/${slug}/-/repositories`} icon={<Settings size={15} />} drill current={inSettings}>
1615 Settings
1616 </SidebarLink>
1617 </div>
1618 </nav>
1619 );
1620 const settings = (
1621 <nav aria-label="Workspace settings" className={PANEL}>
1622 <BackRow to={`/${slug}/-/workspace`} label="Settings" context={slug} />
1623 <div className="mt-2 space-y-px">
1624 {owner && (
1625 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />} end>
1626 General
1627 </SidebarLink>
1628 )}
1629 <SidebarLink to={`/${slug}/-/settings/chat`} icon={<MessagesSquare size={15} />}>
1630 Chat
1631 </SidebarLink>
1632 <SidebarLink to={`/${slug}/-/repositories`} icon={<BookMarked size={15} />}>
1633 Repositories
1634 </SidebarLink>
1635 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
1636 Access tokens
1637 </SidebarLink>
1638 {owner && (
1639 <SidebarLink to={`/${slug}/-/personal-access-tokens`} icon={<UserRoundKey size={15} />}>
1640 Personal access tokens
1641 </SidebarLink>
1642 )}
1643 <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
1644 Webhooks
1645 </SidebarLink>
1646 <SidebarLink to={`/${slug}/-/emoji`} icon={<Smile size={15} />}>
1647 Emoji
1648 </SidebarLink>
1649 </div>
1650 <SidebarGroup title="Runs" className="mt-3">
1651 <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}>
1652 Secrets and variables
1653 </SidebarLink>
1654 <SidebarLink to={`/${slug}/-/actions`} icon={<PlayCircle size={15} />}>
1655 Actions
1656 </SidebarLink>
1657 {owner && (
1658 <SidebarLink to={`/${slug}/-/runners`} icon={<ServerCog size={15} />}>
1659 Runners
1660 </SidebarLink>
1661 )}
1662 </SidebarGroup>
1663 </nav>
1664 );
1665 const trail: Level[] = [{ key: "workspace", node: main }];
1666 if (inSettings) trail.push({ key: "workspace:settings", node: settings });
1667 return (
1668 <div className="flex h-full flex-col">
1669 <ModeHeader title="Workspace" onClose={onClose} />
1670 <Drill trail={trail} />
1671 </div>
1672 );
1673}
1674
1675/** Your own settings, beside the rail: under the account menu, not any workspace's. */
1676export function AccountSidebar({ username, onClose }: { username: string; onClose?: () => void }) {
1677 return (
1678 <div className="flex h-full flex-col">
1679 <ModeHeader title="Your account" onClose={onClose} />
1680 <div className="min-h-0 grow">
1681 <AccountSettingsMenu username={username} />
1682 </div>
1683 </div>
1684 );
1685}
1686
1687
1688/** Words for the sections a path can end in. */
1689const SECTIONS: Record<string, string> = {
1690 issues: "Issues",
1691 milestones: "Milestones",
1692 labels: "Labels",
1693 pulls: "Pull requests",
1694 queue: "Merge queue",
1695 commits: "Commits",
1696 plans: "Outcomes",
1697 actions: "Workflows",
1698 soon: "Soon",
1699 deployments: "Deployments",
1700 repository: "Repository",
1701 access: "Access",
1702 invitations: "Invitation",
1703 repositories: "Repositories",
1704 branches: "Branches",
1705 tags: "Tags",
1706 compare: "Compare",
1707 dependencies: "Dependencies",
1708 code: "Files",
1709 secrets: "Secrets and variables",
1710 settings: "Settings",
1711 people: "People",
1712 projects: "Projects",
1713 teams: "Teams",
1714 tokens: "Access tokens",
1715 "personal-access-tokens": "Personal access tokens",
1716 usage: "Usage",
1717 gateway: "AI Gateway",
1718 billing: "Billing and plans",
1719 integrations: "Integrations",
1720 webhooks: "Webhooks",
1721 emoji: "Emoji",
1722 domains: "Domains",
1723 guardrails: "Guardrails",
1724 rules: "Rules",
1725 audit: "Audit log",
1726 tree: "Files",
1727 blob: "Files",
1728 agents: "Agents",
1729 context: "Context",
1730 memory: "Memory",
1731 security: "Security",
1732 packages: "Packages",
1733 runners: "Runners",
1734 environments: "Environments",
1735 workflows: "Workflows",
1736 observability: "Observability",
1737 insights: "Insights",
1738 sessions: "Sessions",
1739 chat: "Chat",
1740 docs: "Docs",
1741 home: "Home",
1742 "code-access": "Code access",
1743 overview: "Overview",
1744 workspace: "Workspace",
1745};
1746
1747/** Settings pages whose name differs from the section's of the same word. */
1748const SETTINGS_SECTIONS: Record<string, string> = { branches: "Branches and merging", actions: "Actions" };
1749
1750/** Where the page is, as a trail of links: workspace / repository / section. */
1751function Breadcrumbs({ pathname, missing, repo }: { pathname: string; missing?: boolean; repo?: ShellData["repo"] }) {
1752 // A 404 names nothing from the address, unless the project is real and
1753 // visible and only something inside it is missing.
1754 const visibleRepo = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(pathname);
1755 if (missing && !(visibleRepo && sameRepo(repo ?? null, { namespace: visibleRepo[1]!, name: visibleRepo[2]! }))) {
1756 return <span className="text-sm font-medium">Not found</span>;
1757 }
1758 const parts = pathname.split("/").filter(Boolean);
1759 const reserved = ["settings", "explore", "new", "search", "workspaces", "policies", "security", "support", "status", "invite", "inbox"];
1760 if (parts.length === 0) return <span className="text-sm font-medium">Home</span>;
1761 // Your settings: Settings / Emails.
1762 if (parts[0] === "settings") {
1763 const page = accountSettingsPage(pathname);
1764 return <Trail trail={[{ label: "Settings", to: FIRST_SETTINGS_PAGE }, ...(page ? [{ label: ACCOUNT_SETTINGS[page].title, to: `/settings/${page}` }] : [])]} />;
1765 }
1766 if (reserved.includes(parts[0]!)) {
1767 const words: Record<string, string> = {
1768 settings: "Account",
1769 explore: "Explore",
1770 new: "New project",
1771 search: "Search",
1772 workspaces: "New workspace",
1773 policies: "Policies",
1774 security: "Security",
1775 support: "Support",
1776 status: "Status",
1777 invite: "Invite",
1778 inbox: "Inbox",
1779 };
1780 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
1781 }
1782 // A person's profile, by their handle.
1783 if (parts[0] === "u" && parts[1]) return <ProfileCrumb username={parts[1]} />;
1784 const [owner, second, third, fourth] = parts;
1785 const trail: Crumb[] = [{ label: owner!, to: `/${owner}`, mono: true }];
1786 if (second === "-") {
1787 const page = `/${owner}/-/${third}`;
1788 if (third && SETTINGS_PAGE.test(page)) {
1789 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
1790 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
1791 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
1792 } else if (second) {
1793 const repo = `/${owner}/${second}`;
1794 trail.push({ label: second, to: repo, mono: true });
1795 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, short: `#${fourth}`, to: `${repo}/pull/${fourth}` });
1796 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, short: `#${fourth}`, to: `${repo}/issues/${fourth}` });
1797 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
1798 else if (third && SECTIONS[third]) {
1799 trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
1800 // A settings page names which one: Settings / Secrets and variables.
1801 if (third === "settings" && fourth && SECTIONS[fourth]) {
1802 trail.push({ label: SETTINGS_SECTIONS[fourth] ?? SECTIONS[fourth]!, to: `${repo}/settings/${fourth}` });
1803 }
1804 }
1805 }
1806 return <Trail trail={trail} />;
1807}
1808
1809/** A profile's place in the top bar: the person's avatar and @handle, from the page once it has loaded. */
1810function ProfileCrumb({ username }: { username: string }) {
1811 const page = useRouteLoaderData("routes/user") as { profile?: { username: string; displayUsername?: string | null; avatar: string | null } } | undefined;
1812 const profile = page?.profile?.username.toLowerCase() === username.toLowerCase() ? page.profile : null;
1813 return (
1814 <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">
1815 <Avatar name={profile?.username ?? username} image={profile?.avatar ?? null} size={20} />
1816 <span className="truncate font-mono text-[0.8125rem] font-medium">@{profile ? shownUsername(profile) : username}</span>
1817 </Link>
1818 );
1819}
1820
1821/** One link of the trail; `short` is what a phone shows when it is the page itself. */
1822type Crumb = { label: string; to: string; mono?: boolean; short?: string };
1823
1824/**
1825 * The trail of links. On a phone there is room for one: the page itself,
1826 * with a way back to where it sits; the whole trail from a wider screen up.
1827 */
1828function Trail({ trail }: { trail: Crumb[] }) {
1829 // Up on a phone, unless up is the workspace itself: its pages are the tabs.
1830 const up = trail.length > 1 && !/^\/[^/]+$/.test(trail[trail.length - 2]!.to) ? trail[trail.length - 2]! : null;
1831 return (
1832 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
1833 {up && (
1834 <Link
1835 to={up.to}
1836 prefetch="intent"
1837 aria-label={`Back to ${up.label}`}
1838 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"
1839 >
1840 <ChevronLeft size={16} />
1841 </Link>
1842 )}
1843 {trail.map((crumb, index) => {
1844 const last = index === trail.length - 1;
1845 return (
1846 <span key={index} className={`min-w-0 items-center gap-1.5 ${last ? "flex" : "hidden sm:flex"}`}>
1847 {index > 0 && <span className="hidden text-line-strong sm:inline">/</span>}
1848 <Link
1849 to={crumb.to}
1850 prefetch="intent"
1851 aria-current={last ? "page" : undefined}
1852 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
1853 last ? "font-medium text-fg" : "text-muted hover:text-fg"
1854 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
1855 >
1856 {last && crumb.short ? (
1857 <>
1858 <span className="sm:hidden">{crumb.short}</span>
1859 <span className="hidden sm:inline">{crumb.label}</span>
1860 </>
1861 ) : (
1862 crumb.label
1863 )}
1864 </Link>
1865 </span>
1866 );
1867 })}
1868 </nav>
1869 );
1870}
1871
1872type Command = PaletteCommand;
1873
1874/** Everything the palette can jump to, from what the sidebar already knows. */
1875function commandsFor(user: User | null, shell: ShellData, here: string, signUpLabel = "Sign up"): Command[] {
1876 if (!user) return visitorCommands(shell, here, signUpLabel);
1877 const commands: Command[] = [
1878 { label: "Home", to: "/", icon: <House size={15} /> },
1879 ...(shell.workspace
1880 ? [
1881 { label: "Chat", to: `/${shell.workspace.slug}/-/chat`, icon: <MessagesSquare size={15} /> },
1882 { label: "Agents", to: `/${shell.workspace.slug}/-/agents`, icon: <Sparkles size={15} /> },
1883 ...(hasCodeAccess(shell.workspace) ? [{ label: "Code overview", to: `/${shell.workspace.slug}/-/overview`, icon: <Code2 size={15} /> }] : []),
1884 { label: "Workspace", hint: "People, billing, policies, settings", to: `/${shell.workspace.slug}/-/workspace`, icon: <Building2 size={15} /> },
1885 ]
1886 : []),
1887 ...(shell.repos.length > 0
1888 ? [{ label: "Put an agent on it", hint: "Open an issue and assign g1t", to: "/?agent=new", icon: <Sparkles size={15} /> }]
1889 : []),
1890 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
1891 { label: "Search g1t", hint: "Repositories, code, issues, people", to: "/search", icon: <Search size={15} /> },
1892 { label: "Inbox", hint: "What needs you", to: "/inbox", icon: <Bell size={15} /> },
1893 { label: "New project", to: "/new", icon: <Plus size={15} /> },
1894 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
1895 { label: "Your settings", to: FIRST_SETTINGS_PAGE, icon: <Settings size={15} /> },
1896 ...(Object.keys(ACCOUNT_SETTINGS) as AccountSettingsPage[])
1897 .filter((page) => page !== "profile")
1898 .map((page) => ({ label: ACCOUNT_SETTINGS[page].title, hint: "Your settings", to: `/settings/${page}`, icon: <Settings size={15} /> })),
1899 ];
1900 const repo = shell.repo;
1901 if (repo) {
1902 const base = `/${repo.namespace}/${repo.name}`;
1903 const name = `${repo.namespace}/${repo.name}`;
1904 commands.unshift(
1905 { label: "Overview", hint: name, to: base, icon: <LayoutGrid size={15} /> },
1906 { label: "Code", hint: name, to: `${base}/code`, icon: <Code2 size={15} /> },
1907 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
1908 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
1909 ...(repo.member
1910 ? [{ label: `Put an agent on ${repo.name}`, hint: name, to: `${base}/issues/new?agent=1`, icon: <Sparkles size={15} /> }]
1911 : []),
1912 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
1913 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
1914 ...(repo.member
1915 ? [
1916 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
1917 { label: "Deployments", hint: name, to: `${base}/deployments`, icon: <Rocket size={15} /> },
1918 { label: "Secrets and variables", hint: name, to: `${base}/settings/secrets`, icon: <Lock size={15} /> },
1919 { label: "Project settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
1920 { label: "Repository settings", hint: name, to: `${base}/settings/repository`, icon: <Settings size={15} /> },
1921 ]
1922 : []),
1923 );
1924 }
1925 for (const membership of user.workspaces ?? []) {
1926 commands.push(
1927 { label: displayName(membership), hint: `Workspace · ${membership.slug}`, to: `/${membership.slug}`, icon: <Avatar name={membership.slug} image={membership.avatar} size={15} square /> },
1928 { label: "All projects", hint: membership.slug, to: `/${membership.slug}/-/projects`, icon: <LayoutGrid size={15} /> },
1929 { label: "People", hint: membership.slug, to: `/${membership.slug}/-/people`, icon: <Users size={15} /> },
1930 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
1931 { label: "AI Gateway", hint: `${membership.slug} · Usage`, to: `/${membership.slug}/-/gateway`, icon: <Network size={15} /> },
1932 { label: "Billing and plans", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
1933 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
1934 { label: "Integrations", hint: membership.slug, to: `/${membership.slug}/-/integrations`, icon: <Plug size={15} /> },
1935 );
1936 }
1937 for (const listed of shell.repos) {
1938 commands.push({
1939 label: listed.title ?? listed.name,
1940 hint: `${listed.namespace} · Project`,
1941 to: `/${listed.namespace}/${listed.name}`,
1942 icon: <Box size={15} />,
1943 });
1944 }
1945 // "Put an agent on …": one per project, after the projects themselves.
1946 for (const listed of shell.repos) {
1947 if (repo && listed.namespace === repo.namespace && listed.name === repo.name) continue;
1948 commands.push({
1949 label: `Put an agent on ${listed.title ?? listed.name}`,
1950 hint: `${listed.namespace}/${listed.name}`,
1951 to: `/${listed.namespace}/${listed.name}/issues/new?agent=1`,
1952 icon: <Sparkles size={15} />,
1953 });
1954 }
1955 return commands;
1956}
1957
1958/** What the palette offers a visitor: browsing, the project they are in, and signing in. */
1959function visitorCommands(shell: ShellData, here: string, signUpLabel: string): Command[] {
1960 const commands: Command[] = [];
1961 const repo = shell.repo;
1962 if (repo) {
1963 const base = `/${repo.namespace}/${repo.name}`;
1964 const name = `${repo.namespace}/${repo.name}`;
1965 commands.push(
1966 { label: "Overview", hint: name, to: base, icon: <LayoutGrid size={15} /> },
1967 { label: "Code", hint: name, to: `${base}/code`, icon: <Code2 size={15} /> },
1968 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
1969 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
1970 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
1971 );
1972 }
1973 commands.push(
1974 { label: "Explore", hint: "Public projects", to: "/explore", icon: <Compass size={15} /> },
1975 { label: "Search g1t", hint: "Repositories, code, issues, people", to: "/search", icon: <Search size={15} /> },
1976 { label: "Pricing", to: "/pricing", icon: <CreditCard size={15} /> },
1977 { label: "Documentation", to: "https://docs.g1t.sh/", icon: <BookOpen size={15} /> },
1978 { label: "Sign in", to: withNext("/login", here), icon: <LogIn size={15} /> },
1979 { label: signUpLabel, to: withNext("/register", here), icon: <Plus size={15} /> },
1980 );
1981 return commands;
1982}
1983
1984/**
1985 * The page while the next one loads: after a moment it dims, under the
1986 * bar below, so it is plainly on its way out rather than frozen, and the
1987 * next page fades up from there. Sending a form does not dim it: the
1988 * form's button says it is working. Spread on the element holding the page.
1989 */
1990export function useLeaving() {
1991 const navigation = useNavigation();
1992 const method = navigation.formMethod?.toUpperCase();
1993 const leaving = navigation.state === "loading" && (method == null || method === "GET");
1994 return {
1995 "aria-busy": leaving || undefined,
1996 className: `transition-opacity motion-reduce:transition-none ${leaving ? "opacity-60 delay-200 duration-300" : "duration-150"}`,
1997 };
1998}
1999
2000/**
2001 * A bar across the top of the page while the next one loads, as GitHub
2002 * has: it appears at once, creeps towards the end while waiting, then fills
2003 * and fades when the page arrives.
2004 */
2005export function Progress() {
2006 const navigation = useNavigation();
2007 const busy = navigation.state !== "idle";
2008 const [width, setWidth] = useState(0);
2009 const [visible, setVisible] = useState(false);
2010 useEffect(() => {
2011 if (busy) {
2012 setVisible(true);
2013 setWidth(12);
2014 // Slows as it goes, never quite arriving until the page does.
2015 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
2016 return () => clearInterval(timer);
2017 }
2018 setWidth((now) => (now > 0 ? 100 : 0));
2019 const fade = setTimeout(() => setVisible(false), 250);
2020 const reset = setTimeout(() => setWidth(0), 550);
2021 return () => {
2022 clearTimeout(fade);
2023 clearTimeout(reset);
2024 };
2025 }, [busy]);
2026 return (
2027 <div
2028 aria-hidden="true"
2029 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
2030 visible ? "opacity-100" : "opacity-0"
2031 }`}
2032 >
2033 <div
2034 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
2035 style={{ width: `${width}%` }}
2036 />
2037 </div>
2038 );
2039}
2040
2041/** The top bar's way to g1t: the direct message with the workspace's orchestrator, opened or made. */
2042function AskG1tButton({ slug }: { slug: string }) {
2043 return (
2044 <Hint label="Ask g1t">
2045 <Link
2046 to={`/${slug}/-/chat?agent=g1t`}
2047 aria-label="Ask g1t"
2048 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"
2049 >
2050 <G1tMark size={18} />
2051 <span className="hidden sm:inline">Ask g1t</span>
2052 </Link>
2053 </Hint>
2054 );
2055}
2056
2057/** The mode's sidebar beside the rail: a shade lighter than the page. */
2058const SIDEBAR_BOX = "h-full border-r border-line bg-[color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))]";
2059
2060/**
2061 * Which sidebar sits beside the rail: each mode's own, or none (the Inbox,
2062 * which is a page of its own, and g1t's public pages, such as a profile,
2063 * which are no workspace's and carry their own left column). Without a
2064 * workspace (a visitor), the one sidebar there always was.
2065 */
2066function sidebarFor(mode: ModeKey | null): Panel | null {
2067 if (mode == null) return "code";
2068 if (mode === "inbox" || mode === "site") return null;
2069 return mode;
2070}
2071
2072/** The sidebars that sit beside the rail, one per mode that has one. */
2073type Panel = "home" | "chat" | "docs" | "agents" | "code" | "workspace" | "account";
2074
2075/** Each sidebar's name, for the phone's button that opens it. */
2076const MODE_MENU: Record<Panel, string> = {
2077 home: "Home",
2078 chat: "Chat",
2079 docs: "Docs",
2080 agents: "Agents",
2081 code: "Code",
2082 workspace: "Workspace",
2083 account: "Account",
2084};
2085
2086/**
2087 * The app: a sidebar with the workspace, its repositories and the sections
2088 * of the one being looked at; a slim bar with search and the account; and
2089 * the page. A visitor who is not signed in gets the same frame, with
2090 * Explore and Search in place of the workspace, and signing in in place of
2091 * the account.
2092 */
2093export function AppShell({
2094 user,
2095 shell,
2096 missing = false,
2097 banner,
2098 children,
2099}: {
2100 user: User | null;
2101 shell: ShellData;
2102 /** The page is a 404 (see Sidebar). */
2103 missing?: boolean;
2104 banner?: ReactNode;
2105 children: ReactNode;
2106}) {
2107 const { pathname, search } = useLocation();
2108 const [drawer, setDrawer] = useState(false);
2109 const [palette, setPalette] = useState(false);
2110 const here = pathname + search;
2111 const signUpLabel = useSignUpCopy().primary;
2112 const commands = useMemo(() => commandsFor(user, shell, here, signUpLabel), [user, shell, here, signUpLabel]);
2113
2114 // A new page closes the drawer on small screens.
2115 useEffect(() => setDrawer(false), [pathname]);
2116 // Mission control's code, fetched while the browser is idle, so going
2117 // home never waits on it (routes/home.tsx).
2118 useEffect(() => {
2119 if (!user) return;
2120 const load = () => void import("./mission-control").catch(() => undefined);
2121 const idle = (window as { requestIdleCallback?: (callback: () => void) => number }).requestIdleCallback;
2122 if (idle) idle(load);
2123 else setTimeout(load, 1500);
2124 }, [user]);
2125 usePaletteShortcut(() => setPalette((open) => !open));
2126 const leaving = useLeaving();
2127 useVisualViewport();
2128
2129 // The rail and the mode's sidebar (docs/WORKSPACE.md, "Shell"), for
2130 // someone in a workspace. A visitor, or someone with none, keeps the one
2131 // sidebar.
2132 const going = useNavigation().location?.pathname;
2133 const ws = user ? shell.workspace : null;
2134 const mode: ModeKey | null = ws ? modeOf(going ?? pathname, ws.slug) : null;
2135 const panel = ws ? sidebarFor(mode) : "code";
2136 const code = ws ? hasCodeAccess(ws) : true;
2137 const { sidebar: chatSidebar } = useChatSidebar();
2138 // Notify: the feed socket's live counts (lib/notify-client.ts) once it has them; the page's until then.
2139 const live = useLiveBadges(ws?.slug);
2140 const chatUnread = chatSidebar
2141 ? unreadTotals(chatSidebar.entries)
2142 : live?.chat != null
2143 ? { unread: live.chat, mentions: live.mentions ?? 0 }
2144 : (shell.chat ?? { unread: 0, mentions: 0 });
2145 const inboxUnread = live?.inbox ?? shell.inbox?.unread ?? 0;
2146 const sidebarNode = (inSheet: boolean) => {
2147 const close = inSheet ? () => setDrawer(false) : undefined;
2148 const find = () => {
2149 if (inSheet) setDrawer(false);
2150 setPalette(true);
2151 };
2152 if (!ws || !user || panel === "code") {
2153 return <Sidebar user={user} shell={shell} missing={missing} onFind={find} onClose={close} rail={Boolean(ws && user)} />;
2154 }
2155 switch (panel) {
2156 case "home":
2157 return <HomeSidebar slug={ws.slug} shell={shell} code={code} onFind={find} header={<ModeHeader title="Home" onClose={close} />} />;
2158 case "chat":
2159 return <ChatSidebar slug={ws.slug} />;
2160 case "docs":
2161 return <DocsSidebar slug={ws.slug} onClose={close} />;
2162 case "agents":
2163 return <AgentsSidebar slug={ws.slug} shellAgents={shell.agents ?? null} code={code} owner={ws.role === "owner"} />;
2164 case "workspace":
2165 return <WorkspaceSidebar slug={ws.slug} owner={ws.role === "owner"} onClose={close} />;
2166 case "account":
2167 return <AccountSidebar username={user.username} onClose={close} />;
2168 default:
2169 return null;
2170 }
2171 };
2172 const rail =
2173 user && ws ? (
2174 <Rail
2175 user={user}
2176 workspace={ws}
2177 unread={{ inbox: inboxUnread, chat: chatUnread.unread, mentions: chatUnread.mentions }}
2178 help={<HelpMenu />}
2179 account={<AccountMenu user={user} rail />}
2180 />
2181 ) : null;
2182 const pad = rail ? (panel ? "lg:pl-[21rem]" : "lg:pl-20") : "lg:pl-64";
2183 // On a phone (below 768px): the tab bar, and a conversation full screen.
2184 const conversation = isConversation(going ?? pathname);
2185 const tabs = Boolean(user && ws) && !conversation;
2186
2187 return (
2188 // The phone's menu is a sheet: a dialog that holds focus, closes on
2189 // Escape or a tap outside, and gives focus back to the menu button.
2190 <Sheet open={drawer} onOpenChange={setDrawer}>
2191 <div className="min-h-dvh">
2192 <Progress />
2193 <aside className="fixed inset-y-0 left-0 z-40 hidden lg:flex">
2194 {rail}
2195 {panel && <div className={`${SIDEBAR_BOX} w-64`}>{sidebarNode(false)}</div>}
2196 </aside>
2197 <SheetContent
2198 side="left"
2199 showClose={false}
2200 aria-describedby={undefined}
2201 // Focus would land on the workspace's avatar and open its hint over
2202 // the sidebar's heading; the sheet itself takes it instead.
2203 onOpenAutoFocus={(event) => {
2204 event.preventDefault();
2205 (event.currentTarget as HTMLElement | null)?.focus();
2206 }}
2207 // A phone has the tab bar for the modes: the sheet is the mode's
2208 // sidebar alone, a little wider, its rows tall enough for a thumb.
2209 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"}`}
2210 >
2211 <SheetTitle className="sr-only">{ws && panel ? `${MODE_MENU[panel]} menu` : "Menu"}</SheetTitle>
2212 {rail && <div className={`flex h-full shrink-0 ${panel ? "max-md:hidden" : ""}`}>{rail}</div>}
2213 {panel && <div className={`min-w-0 grow ${rail ? SIDEBAR_BOX : ""} max-md:[&_nav_a]:min-h-10`}>{sidebarNode(true)}</div>}
2214 </SheetContent>
2215
2216 <div className={`flex min-h-dvh min-w-0 flex-col ${pad} ${tabs ? "pb-(--tabbar-h)" : ""}`}>
2217 <header
2218 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" : ""}`}
2219 >
2220 {/* A phone: the workspace's avatar opens everything else (components/mobile.tsx). */}
2221 {user && ws && <AvatarSheetButton user={user} workspace={ws} />}
2222 {/* The mode's own menu (its sidebar); on a phone, beside the avatar, wherever the mode has one. */}
2223 <SheetTrigger
2224 aria-label={ws && panel ? `Open the ${MODE_MENU[panel]} menu` : "Open menu"}
2225 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" : ""}`}
2226 >
2227 <Menu size={18} />
2228 </SheetTrigger>
2229 <Breadcrumbs pathname={pathname} missing={missing} repo={shell.repo} />
2230 <div className="ml-auto flex items-center gap-1.5">
2231 {/* Search lives in the sidebar ("Search or jump to"); with the sidebar folded away, this opens the same palette. */}
2232 <button
2233 type="button"
2234 aria-label="Search or jump to"
2235 onClick={() => setPalette(true)}
2236 className="flex size-9 items-center justify-center rounded-md text-muted transition-colors hover:bg-raised hover:text-fg lg:hidden"
2237 >
2238 <Search size={16} />
2239 </button>
2240 {/* All of g1t's public projects: not any one workspace's, so here, not in the sidebar. */}
2241 <NavLink
2242 to="/explore"
2243 prefetch="intent"
2244 aria-label="Explore"
2245 className={({ isActive }) =>
2246 `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"}`
2247 }
2248 >
2249 <Compass size={16} className="sm:hidden" />
2250 <span className="hidden sm:inline">Explore</span>
2251 </NavLink>
2252 <a
2253 href="https://docs.g1t.sh/"
2254 className="hidden rounded-md px-2.5 py-1.5 text-sm text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
2255 >
2256 Docs
2257 </a>
2258 {user && (
2259 <>
2260 {shell.workspace ? <AskG1tButton slug={shell.workspace.slug} /> : <AgentButton />}
2261 {/* On a phone the Inbox is a tab. */}
2262 <span className={shell.workspace ? "max-md:hidden" : undefined}>
2263 <InboxBell counts={shell.inbox ? { ...shell.inbox, unread: inboxUnread } : null} />
2264 </span>
2265 </>
2266 )}
2267 {!user ? (
2268 // The sidebar has these too, but on a phone it is folded away.
2269 <Link
2270 to={withNext("/login", here)}
2271 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"
2272 >
2273 Sign in
2274 </Link>
2275 ) : (
2276 <>
2277 {/* Room between what comes in (the inbox) and what you make. */}
2278 <span aria-hidden="true" className="mx-1 h-5 w-px bg-line" />
2279 <DropdownMenu>
2280 <Tooltip>
2281 <TooltipTrigger asChild>
2282 <DropdownMenuTrigger
2283 aria-label="Create new"
2284 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"
2285 >
2286 <Plus size={16} />
2287 <ChevronDown size={13} className="text-muted" />
2288 </DropdownMenuTrigger>
2289 </TooltipTrigger>
2290 <TooltipContent>Create new…</TooltipContent>
2291 </Tooltip>
2292 <DropdownMenuContent align="end" className="min-w-52">
2293 {shell.repo && (
2294 <>
2295 <DropdownMenuLabel className="truncate">In {shell.repo.name}</DropdownMenuLabel>
2296 <DropdownMenuItem asChild>
2297 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
2298 <CircleDot />
2299 New issue
2300 </Link>
2301 </DropdownMenuItem>
2302 {shell.repo.member && (
2303 <DropdownMenuItem asChild>
2304 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
2305 <ListTree />
2306 Plan work
2307 </Link>
2308 </DropdownMenuItem>
2309 )}
2310 <DropdownMenuSeparator />
2311 </>
2312 )}
2313 <DropdownMenuItem asChild>
2314 <Link to="/?agent=new">
2315 <Sparkles />
2316 Put an agent on it
2317 </Link>
2318 </DropdownMenuItem>
2319 <DropdownMenuItem asChild>
2320 <Link to="/new">
2321 <Box />
2322 New project
2323 </Link>
2324 </DropdownMenuItem>
2325 <DropdownMenuItem asChild>
2326 <Link to="/new/github">
2327 <GitBranch />
2328 Import from GitHub
2329 </Link>
2330 </DropdownMenuItem>
2331 <DropdownMenuSeparator />
2332 <DropdownMenuItem asChild>
2333 <Link to="/workspaces/new">
2334 <Users />
2335 New workspace
2336 </Link>
2337 </DropdownMenuItem>
2338 {/* A team in the workspace the sidebar is about, for whoever it lets create one. */}
2339 {shell.workspace &&
2340 (mayCreateTeams(shell.workspace.team_creation, shell.workspace.role) ? (
2341 <DropdownMenuItem asChild>
2342 <Link to={`/${shell.workspace.slug}/-/teams/new`}>
2343 <UsersRound />
2344 New team
2345 </Link>
2346 </DropdownMenuItem>
2347 ) : (
2348 <DropdownMenuItem disabled title="Only owners can create teams in this workspace.">
2349 <UsersRound />
2350 New team
2351 <span className="ml-auto pl-3 text-xs text-faint">Owners only</span>
2352 </DropdownMenuItem>
2353 ))}
2354 </DropdownMenuContent>
2355 </DropdownMenu>
2356 </>
2357 )}
2358 </div>
2359 </header>
2360 {banner}
2361 <main id="content" tabIndex={-1} {...leaving} className={`min-w-0 grow outline-none ${leaving.className}`}>
2362 <InMain.Provider value={true}>{children}</InMain.Provider>
2363 </main>
2364 </div>
2365 {user && ws && (
2366 <MobileTabBar
2367 workspace={ws}
2368 unread={{ inbox: inboxUnread, chat: chatUnread.unread, mentions: chatUnread.mentions }}
2369 onReselect={panel ? () => setDrawer(true) : undefined}
2370 />
2371 )}
2372 <CommandPalette
2373 open={palette}
2374 onOpenChange={setPalette}
2375 commands={commands}
2376 repo={shell.repo ? `${shell.repo.namespace}/${shell.repo.name}` : null}
2377 />
2378 </div>
2379 </Sheet>
2380 );
2381}