Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

613 linesCodeBlameRaw
1import {
2 Activity,
3 BellOff,
4 BellRing,
5 BookOpen,
6 CircleUserRound,
7 Compass,
8 Ellipsis,
9 Keyboard,
10 LayoutGrid,
11 LifeBuoy,
12 LogOut,
13 Moon,
14 Settings,
15 Smile,
16 Sun,
17} from "lucide-react";
18import { Dialog as Primitive } from "radix-ui";
19import { type ReactNode, useEffect, useRef, useState } from "react";
20import { Link, useLocation, useNavigate, useSubmit } from "react-router";
21
22import { type Abilities, type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts";
23
24import { appIcon, pinnedApp } from "./apps";
25import { CountBadge, ShortcutsDialog } from "./rail";
26import { Mark } from "./logo";
27
28import { Avatar } from "./ui/avatar";
29import { NavTab, NavTabs } from "./ui/nav-tabs";
30import { StatusDialog } from "./presence";
31import { ThemeSwitch } from "./theme-switch";
32import { type BuiltinApp, PHONE_TABS, type PinnableApp, appOf, phoneMoreApps } from "../lib/apps";
33import { setPresence, useOwnPresence } from "../lib/notify-client";
34import { dndOn, liveStatus, pauseUntil, untilLabel } from "../lib/presence";
35import { STATUS_URL } from "../lib/status";
36import { type ModeKey, modeHome, modeOf } from "../lib/workspace-nav";
37import { PROJECT_PAGE_LINKS, type ProjectPage, projectPageAt, projectPages } from "../lib/chrome";
38import { ROADMAP, type RoadmapItem } from "../lib/roadmap";
39import { repoWorkPath } from "../lib/work-items";
40
41/**
42 * The phone's layout (below 768px): a bottom bar of apps with More for
43 * the rest, the mode's sidebar as a drawer from the left, and each tab a
44 * list that pushes its detail full screen. A computer has the rail.
45 */
46
47/** Whether a path is one conversation, which takes the whole screen on a phone. */
48export function isConversation(pathname: string): boolean {
49 return /^\/[^/]+\/-\/chat\/(?!browse\/?$)[^/]+/.test(pathname);
50}
51
52/**
53 * Follows the visual viewport, which shrinks when the keyboard opens: its
54 * height and top go on the page as `--vv-height` and `--vv-top`, what
55 * the keyboard covers as `--keyboard-inset`, and
56 * `data-keyboard="open"` on <html> while the keyboard is up, so a
57 * composer can sit just above it and the tab bar can step aside.
58 */
59export function useVisualViewport() {
60 useEffect(() => {
61 const viewport = window.visualViewport;
62 if (!viewport) return;
63 const root = document.documentElement;
64 const update = () => {
65 root.style.setProperty("--vv-height", `${viewport.height}px`);
66 root.style.setProperty("--vv-top", `${viewport.offsetTop}px`);
67 // How much of the bottom the keyboard covers, for a sheet to sit on it.
68 root.style.setProperty("--keyboard-inset", `${Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop)}px`);
69 const keyboard = window.innerHeight - viewport.height > 140;
70 if (keyboard) root.dataset.keyboard = "open";
71 else delete root.dataset.keyboard;
72 };
73 update();
74 viewport.addEventListener("resize", update);
75 viewport.addEventListener("scroll", update);
76 return () => {
77 viewport.removeEventListener("resize", update);
78 viewport.removeEventListener("scroll", update);
79 };
80 }, []);
81}
82
83/**
84 * Swipe from the left edge to go back, as on a phone's own apps: a touch
85 * that starts within 28px of the edge and travels 80px right, more across
86 * than down. Going back is the browser's own back, so history stays true.
87 */
88export function useSwipeBack(onBack: () => void) {
89 const start = useRef<{ x: number; y: number } | null>(null);
90 return {
91 onTouchStart: (event: React.TouchEvent) => {
92 const touch = event.touches[0];
93 start.current = touch && touch.clientX < 28 ? { x: touch.clientX, y: touch.clientY } : null;
94 },
95 onTouchMove: (event: React.TouchEvent) => {
96 const touch = event.touches[0];
97 if (!start.current || !touch) return;
98 const dx = touch.clientX - start.current.x;
99 const dy = Math.abs(touch.clientY - start.current.y);
100 if (dx > 80 && dx > dy * 1.5) {
101 start.current = null;
102 onBack();
103 }
104 },
105 onTouchEnd: () => {
106 start.current = null;
107 },
108 };
109}
110
111/** Back, as the browser would; to `fallback` when this page was the first one opened. */
112export function useBack(fallback: string) {
113 const navigate = useNavigate();
114 return () => {
115 const index = (window.history.state as { idx?: number } | null)?.idx ?? 0;
116 if (index > 0) navigate(-1);
117 else navigate(fallback, { replace: true });
118 };
119}
120
121type Tab = { key: ModeKey; label: string; badge?: { count: number; loud: boolean } };
122
123/**
124 * The bottom bar of a phone, flat along the bottom edge under a rule, as
125 * the rail stands flat beside the page on a computer: Home, Work, Chat and
126 * Notifications (lib/apps.ts `PHONE_TABS`), each with what is unread, and
127 * More, whose sheet starts with Code: a phone triages and talks about work
128 * far more than it browses code. The tab you are in has the rail's own
129 * mark: its icon on the sidebar's accent. It steps aside while the keyboard is up
130 * and inside a conversation. The tab you are in, tapped again, opens its
131 * mode's menu (its sidebar: Work's saved views, labels and milestones).
132 */
133export function BottomBar({
134 user,
135 workspace,
136 mode: shellMode,
137 pins,
138 unread,
139 onReselect,
140}: {
141 user: User;
142 workspace: Membership;
143 /** The mode the shell is in, when it knows more than the address (`browse`, lib/workspace-nav.ts `shellMode`). */
144 mode?: ModeKey;
145 /** The apps pinned to this person's rail here, for the More sheet. */
146 pins: PinnableApp[];
147 unread: { notifications: number; chat: number; mentions: number };
148 /** Tapping the tab you are already in: the shell opens that mode's menu (its sidebar). */
149 onReselect?: () => void;
150}) {
151 const { pathname } = useLocation();
152 const [more, setMore] = useState(false);
153 useEffect(() => setMore(false), [pathname]);
154 const slug = workspace.slug;
155 const mode = shellMode ?? modeOf(pathname, slug);
156 const badges: Partial<Record<ModeKey, Tab["badge"]>> = {
157 chat: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 },
158 notifications: { count: unread.notifications, loud: true },
159 };
160 const tabs: Tab[] = PHONE_TABS.map((key) => ({ key, label: appOf(key).name, badge: badges[key] }));
161 const inTabs = tabs.some((tab) => tab.key === mode);
162 if (isConversation(pathname)) return null;
163 return (
164 <>
165 <nav
166 aria-label="Apps"
167 className="fixed inset-x-0 bottom-0 z-40 h-(--tabbar-h) border-t border-line bg-rail pb-[env(safe-area-inset-bottom)] md:hidden in-data-[keyboard=open]:hidden"
168 >
169 <ul className="grid h-full" style={{ gridTemplateColumns: `repeat(${tabs.length + 1}, minmax(0, 1fr))` }}>
170 {tabs.map((tab) => {
171 const current = mode === tab.key && !more;
172 const count = tab.badge?.count ?? 0;
173 const to = modeHome(tab.key, slug);
174 return (
175 <li key={tab.key} className="min-w-0">
176 <Link
177 to={to}
178 prefetch="intent"
179 aria-current={current ? "page" : undefined}
180 aria-label={count > 0 ? `${tab.label}, ${count} unread` : tab.label}
181 onClick={(event) => {
182 // The tab you are in, tapped again: its menu, as a phone's own apps do.
183 // Chat's first page on a phone is its sidebar already.
184 if (!current || !onReselect || (tab.key === "chat" && pathname.replace(/\/$/, "") === to)) return;
185 event.preventDefault();
186 onReselect();
187 }}
188 className={BAR_ITEM}
189 >
190 <BarIcon current={current}>
191 {appIcon(tab.key as BuiltinApp, 20)}
192 {tab.badge && <CountBadge count={count} loud={tab.badge.loud} />}
193 </BarIcon>
194 <BarLabel current={current}>{tab.label}</BarLabel>
195 </Link>
196 </li>
197 );
198 })}
199 <li className="min-w-0">
200 <button type="button" aria-expanded={more} aria-haspopup="dialog" onClick={() => setMore(true)} className={BAR_ITEM}>
201 <BarIcon current={more || !inTabs}>
202 <Ellipsis size={20} />
203 </BarIcon>
204 <BarLabel current={more || !inTabs}>More</BarLabel>
205 </button>
206 </li>
207 </ul>
208 </nav>
209 <MoreSheet open={more} onOpenChange={setMore} user={user} workspace={workspace} pins={pins} onExplore={mode === "browse" ? onReselect : undefined} />
210 </>
211 );
212}
213
214const BAR_ITEM = "group relative flex h-full w-full flex-col items-center justify-center gap-1 outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset";
215
216/** A tab's icon, marked as the rail marks the app you are in (ui/sidebar.tsx, `size: icon`). */
217function BarIcon({ current, children }: { current: boolean; children: ReactNode }) {
218 return (
219 <span
220 className={`relative flex h-7 w-11 items-center justify-center rounded-[10px] transition-colors group-active:bg-sidebar-accent/60 ${current ? "bg-sidebar-accent text-fg" : "text-muted"}`}
221 >
222 {children}
223 </span>
224 );
225}
226
227/** A tab's name, centred under its icon; a long one gives way rather than crowding its neighbours. */
228function BarLabel({ current, children }: { current: boolean; children: ReactNode }) {
229 return (
230 <span className={`block w-full truncate px-0.5 text-center text-[0.6875rem] leading-none font-medium max-[380px]:text-[0.625rem] ${current ? "text-fg" : "text-faint"}`}>
231 {children}
232 </span>
233 );
234}
235
236/** A big tile in the More sheet. */
237function MoreTile({ to, icon, label, onClick, disabled, note }: { to?: string; icon: ReactNode; label: string; onClick?: () => void; disabled?: boolean; note?: string }) {
238 const inner = (
239 <>
240 <span className={`flex size-11 items-center justify-center rounded-[10px] ${disabled ? "bg-raised/60 text-faint" : "bg-raised text-fg"}`}>{icon}</span>
241 <span className={`line-clamp-2 w-full text-xs leading-tight max-[380px]:text-[0.6875rem] ${disabled ? "text-faint" : "text-fg-soft"}`}>{label}</span>
242 {note && <span className="-mt-0.5 rounded-full bg-line px-1.5 text-[0.625rem] font-medium text-muted">{note}</span>}
243 </>
244 );
245 const className = "flex min-w-0 flex-col items-center gap-1.5 rounded-md px-1 py-2.5 text-center outline-none focus-visible:ring-2 focus-visible:ring-accent active:bg-raised/60";
246 if (to) {
247 return (
248 <Link to={to} className={className}>
249 {inner}
250 </Link>
251 );
252 }
253 return (
254 <button type="button" onClick={onClick} disabled={disabled} aria-disabled={disabled} className={className}>
255 {inner}
256 </button>
257 );
258}
259
260/**
261 * More, from the bottom bar: a flat panel standing on it, with g1t's
262 * mark at its top and a grid of big tiles: Code (for a member with Code
263 * access), all apps, your pinned apps, Artifacts, Routines, People,
264 * Workspace, Explore, and "You and
265 * help", which opens your account's sheet. Browsing beyond your
266 * workspaces, Explore opens Explore's menu (`onExplore`) rather than the
267 * page.
268 */
269function MoreSheet({
270 open,
271 onOpenChange,
272 user,
273 workspace,
274 pins,
275 onExplore,
276}: {
277 open: boolean;
278 onOpenChange: (open: boolean) => void;
279 user: User;
280 workspace: Membership;
281 pins: PinnableApp[];
282 onExplore?: () => void;
283}) {
284 const slug = workspace.slug;
285 const [account, setAccount] = useState(false);
286 const shown = pins.map((key) => pinnedApp(key, slug)).filter((app) => app != null);
287 const apps = phoneMoreApps(hasCodeAccess(workspace));
288 return (
289 <>
290 <Primitive.Root open={open} onOpenChange={onOpenChange}>
291 <Primitive.Portal>
292 <Primitive.Overlay className="fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out md:hidden" />
293 <Primitive.Content
294 aria-describedby={undefined}
295 onOpenAutoFocus={(event) => event.preventDefault()}
296 className="fixed inset-x-0 bottom-(--tabbar-h) z-50 max-h-[calc(100dvh-var(--tabbar-h)-3.5rem)] overflow-y-auto border-t border-line bg-rail px-3 pt-2 pb-3.5 text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out md:hidden"
297 >
298 <div aria-hidden="true" className="mx-auto mt-0.5 mb-2 h-1 w-9 rounded-full bg-line-strong" />
299 <div className="mb-2 flex items-center gap-2 px-1">
300 <Mark tight className="h-4 w-auto" />
301 <Primitive.Title className="text-sm font-semibold">More</Primitive.Title>
302 <span className="ml-auto truncate font-mono text-xs text-faint">{slug}</span>
303 </div>
304 <div className="grid grid-cols-4 gap-1.5">
305 {/* Code first, for a member who has it: it left the bar for Work. */}
306 {apps.includes("code") && <MoreTile to={appOf("code").path(slug)} icon={appIcon("code", 20)} label="Code" />}
307 <MoreTile to={`/${slug}/-/apps`} icon={<LayoutGrid size={20} />} label="All apps" />
308 {shown.map((app) => (
309 <MoreTile key={app.key} to={app.to} icon={app.icon(26)} label={app.name} />
310 ))}
311 {apps
312 .filter((key) => key !== "code")
313 .map((key) => (
314 <MoreTile key={key} to={appOf(key).path(slug)} icon={appIcon(key, 20)} label={appOf(key).name} />
315 ))}
316 {onExplore ? (
317 <MoreTile
318 icon={<Compass size={20} />}
319 label="Explore"
320 onClick={() => {
321 onOpenChange(false);
322 onExplore();
323 }}
324 />
325 ) : (
326 <MoreTile to="/explore" icon={<Compass size={20} />} label="Explore" />
327 )}
328 <MoreTile
329 icon={<Avatar name={user.username} image={user.avatar} size={24} />}
330 label="You and help"
331 onClick={() => {
332 onOpenChange(false);
333 setAccount(true);
334 }}
335 />
336 </div>
337 </Primitive.Content>
338 </Primitive.Portal>
339 </Primitive.Root>
340 <AccountSheet open={account} onOpenChange={setAccount} user={user} />
341 </>
342 );
343}
344/**
345 * A sheet that rises from the bottom of a phone, with a handle to drag it
346 * back down. Escape, a tap outside or a drag of more than 90px closes it.
347 */
348export function BottomSheet({
349 open,
350 onOpenChange,
351 title,
352 children,
353}: {
354 open: boolean;
355 onOpenChange: (open: boolean) => void;
356 title: string;
357 children: ReactNode;
358}) {
359 const [drag, setDrag] = useState(0);
360 const from = useRef<number | null>(null);
361 useEffect(() => {
362 if (!open) setDrag(0);
363 }, [open]);
364 const handlers = {
365 onPointerDown: (event: React.PointerEvent) => {
366 from.current = event.clientY;
367 (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
368 },
369 onPointerMove: (event: React.PointerEvent) => {
370 if (from.current == null) return;
371 setDrag(Math.max(0, event.clientY - from.current));
372 },
373 onPointerUp: () => {
374 if (drag > 90) onOpenChange(false);
375 else setDrag(0);
376 from.current = null;
377 },
378 };
379 return (
380 <Primitive.Root open={open} onOpenChange={onOpenChange}>
381 <Primitive.Portal>
382 <Primitive.Overlay className="fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out" />
383 <Primitive.Content
384 aria-describedby={undefined}
385 onOpenAutoFocus={(event) => event.preventDefault()}
386 style={{ transform: drag ? `translateY(${drag}px)` : undefined, transition: from.current == null ? "transform 0.2s ease-out" : "none" }}
387 className="fixed inset-x-0 bottom-0 z-50 flex max-h-[88dvh] flex-col border-t border-line bg-surface pb-[env(safe-area-inset-bottom)] text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-[sheet-in-bottom_0.24s_cubic-bezier(0.16,1,0.3,1)]"
388 >
389 <div {...handlers} className="flex shrink-0 cursor-grab touch-none justify-center pt-2.5 pb-2" aria-hidden="true">
390 <span className="h-1.5 w-10 rounded-full bg-line-strong" />
391 </div>
392 <Primitive.Title className="sr-only">{title}</Primitive.Title>
393 <div className="min-h-0 grow overflow-y-auto overscroll-contain px-3 pb-3">{children}</div>
394 </Primitive.Content>
395 </Primitive.Portal>
396 </Primitive.Root>
397 );
398}
399
400/** A row in a bottom sheet: 48px tall, an icon, words, and what sits at its end. */
401export function SheetRow({ to, href, icon, children, end, onClick }: { to?: string; href?: string; icon: ReactNode; children: ReactNode; end?: ReactNode; onClick?: () => void }) {
402 const className = "flex h-12 w-full items-center gap-3 rounded-md px-3 text-left text-[0.9375rem] text-fg-soft transition-colors active:bg-raised";
403 const inner = (
404 <>
405 <span className="flex w-6 shrink-0 justify-center text-muted [&_svg]:size-5">{icon}</span>
406 <span className="min-w-0 grow truncate">{children}</span>
407 {end}
408 </>
409 );
410 if (to) {
411 return (
412 <Link to={to} className={className} onClick={onClick}>
413 {inner}
414 </Link>
415 );
416 }
417 if (href) {
418 return (
419 <a href={href} className={className}>
420 {inner}
421 </a>
422 );
423 }
424 return (
425 <button type="button" onClick={onClick} className={className}>
426 {inner}
427 </button>
428 );
429}
430
431function SheetGroup({ title, children }: { title: string; children: ReactNode }) {
432 return (
433 <section className="mt-2 border-t border-line pt-2">
434 <h3 className="px-3 pt-1 pb-1 text-xs font-medium text-faint">{title}</h3>
435 {children}
436 </section>
437 );
438}
439
440/** Your status, away and pausing notifications, as the account menu has them on a computer. */
441function OwnPresenceRows({ onEdit }: { onEdit: () => void }) {
442 const me = useOwnPresence();
443 const now = Date.now();
444 const status = liveStatus(me?.status, now);
445 const paused = dndOn(me, now);
446 const away = me?.away_manual === true;
447 return (
448 <>
449 <SheetRow icon={status?.emoji ? <span className="text-lg leading-none">{status.emoji}</span> : <Smile />} onClick={onEdit}>
450 {status ? status.text || "Status set" : "Set a status"}
451 </SheetRow>
452 <SheetRow icon={away ? <Sun /> : <Moon />} onClick={() => void setPresence({ away: !away }, { away_manual: !away, presence: away ? "active" : "away" })}>
453 {away ? "Set yourself active" : "Set yourself away"}
454 </SheetRow>
455 {paused ? (
456 <SheetRow
457 icon={<BellRing />}
458 end={me?.dnd_until ? <span className="text-xs text-faint">{untilLabel(me.dnd_until, new Date(now))}</span> : null}
459 onClick={() => void setPresence({ dnd_until: null }, { dnd_until: null })}
460 >
461 Resume notifications
462 </SheetRow>
463 ) : (
464 <SheetRow
465 icon={<BellOff />}
466 onClick={() => {
467 const until = pauseUntil("1h", new Date());
468 void setPresence({ dnd_until: until }, { dnd_until: until });
469 }}
470 >
471 Pause notifications for 1 hour
472 </SheetRow>
473 )}
474 </>
475 );
476}
477
478/**
479 * You and help, on a phone: your status, your profile and settings, the
480 * documentation, support, status and the keyboard's shortcuts, and signing
481 * out, as the account menu has them on a computer.
482 */
483export function AccountSheet({ open, onOpenChange, user }: { open: boolean; onOpenChange: (open: boolean) => void; user: User }) {
484 const submit = useSubmit();
485 const [editing, setEditing] = useState(false);
486 const [keys, setKeys] = useState(false);
487 return (
488 <>
489 <BottomSheet open={open} onOpenChange={onOpenChange} title="You and help">
490 <div className="flex items-center gap-3 px-3 pt-1 pb-3">
491 <Avatar name={user.username} image={user.avatar} size={44} />
492 <div className="min-w-0">
493 <p className="truncate text-base font-semibold">@{shownUsername(user)}</p>
494 </div>
495 </div>
496 <SheetGroup title="You">
497 <OwnPresenceRows
498 onEdit={() => {
499 onOpenChange(false);
500 setEditing(true);
501 }}
502 />
503 <SheetRow to={`/u/${user.username}`} icon={<CircleUserRound />}>
504 Your profile
505 </SheetRow>
506 <SheetRow to="/settings" icon={<Settings />}>
507 Your settings
508 </SheetRow>
509 </SheetGroup>
510 <SheetGroup title="Appearance">
511 <ThemeSwitch size="large" className="mx-3 mt-1 mb-1.5" />
512 </SheetGroup>
513 <SheetGroup title="Help">
514 <SheetRow href="https://docs.g1t.sh/" icon={<BookOpen />}>
515 Documentation
516 </SheetRow>
517 <SheetRow to="/support" icon={<LifeBuoy />}>
518 Support
519 </SheetRow>
520 <SheetRow href={STATUS_URL} icon={<Activity />}>
521 Status
522 </SheetRow>
523 <SheetRow
524 icon={<Keyboard />}
525 onClick={() => {
526 onOpenChange(false);
527 setKeys(true);
528 }}
529 >
530 Keyboard shortcuts
531 </SheetRow>
532 </SheetGroup>
533 <SheetGroup title="Account">
534 <SheetRow icon={<LogOut />} onClick={() => submit(null, { method: "post", action: "/logout" })}>
535 Sign out
536 </SheetRow>
537 </SheetGroup>
538 </BottomSheet>
539 <StatusDialog open={editing} onOpenChange={setEditing} />
540 <ShortcutsDialog open={keys} onOpenChange={setKeys} />
541 </>
542 );
543}
544
545/** Which project page a roadmap page (`soon/<key>`) sits under, by its section. */
546const SOON_UNDER: Partial<Record<RoadmapItem["section"], ProjectPage>> = {
547 Code: "code",
548 Deployments: "deployments",
549 Observability: "observability",
550 Security: "security",
551 Insights: "insights",
552};
553// Read from the roadmap on first use, not as the module loads: a bundle can
554// load this before the roadmap's chunk (lib/project-nav.ts says more).
555let soonPages: Record<string, ProjectPage> | null = null;
556const soonPagesOf = (): Record<string, ProjectPage> =>
557 (soonPages ??= Object.fromEntries(
558 ROADMAP.flatMap((item) => (SOON_UNDER[item.section] ? [[item.key, SOON_UNDER[item.section]!] as const] : [])),
559 ));
560
561/**
562 * A project's pages along the bottom of its header: the sidebar's own list
563 * (lib/chrome.ts) as a row of tabs that scrolls sideways on a phone, the
564 * current one underlined on the header's border and kept in view, so Work
565 * and Pull requests are one tap away. Work is the workspace's Work list
566 * filtered to the project (lib/work-items.ts `repoWorkPath`). From 768px a member's sidebar has
567 * them; a visitor, who has none, always sees the row.
568 */
569export function ProjectStrip({
570 base,
571 member,
572 can,
573 settings,
574 counts,
575 always = false,
576}: {
577 /** `/<namespace>/<name>`. */
578 base: string;
579 member: boolean;
580 can?: Partial<Abilities>;
581 /** Whether they see its settings (lib/access.ts `seesSettings`), as the sidebar decides. */
582 settings?: boolean;
583 counts?: { items: number; pulls: number };
584 /** On a computer too: for a visitor, who has no sidebar to list them. */
585 always?: boolean;
586}) {
587 const { pathname } = useLocation();
588 const rest = pathname.toLowerCase().startsWith(base.toLowerCase()) ? pathname.slice(base.length) : "";
589 const at = projectPageAt(rest, soonPagesOf());
590 const pages: (ProjectPage | "overview")[] = ["overview", ...projectPages(member, can).filter((page) => page !== "observability" && page !== "settings"),
591 ...(settings ?? member ? (["settings"] as const) : []),
592 ];
593 return (
594 <NavTabs label="Project" className={always ? undefined : "md:hidden"}>
595 {pages.map((page) => {
596 const link = page === "overview" ? { label: "Overview", path: "" } : PROJECT_PAGE_LINKS[page];
597 const [namespace = "", name = ""] = base.split("/").filter(Boolean);
598 const to = page === "work" ? repoWorkPath(namespace, name) : link.path ? `${base}/${link.path}` : base;
599 return (
600 <NavTab
601 key={page}
602 to={to}
603 current={at === page}
604 soon={page !== "overview" && PROJECT_PAGE_LINKS[page].soon}
605 count={page === "work" ? counts?.items : page === "pulls" ? counts?.pulls : undefined}
606 >
607 {link.label}
608 </NavTab>
609 );
610 })}
611 </NavTabs>
612 );
613}