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