Skip to content
520 linesCodeBlameRaw
1import {
2 Activity,
3 BarChart3,
4 BellOff,
5 BellRing,
6 BookOpen,
7 Building2,
8 Check,
9 ChevronRight,
10 CircleUserRound,
11 Code2,
12 House,
13 Inbox,
14 LifeBuoy,
15 LogOut,
16 MessagesSquare,
17 Moon,
18 Plug,
19 Plus,
20 Settings,
21 Smile,
22 Sparkles,
23 Sun,
24 Users,
25 UsersRound,
26} from "lucide-react";
27import { Dialog as Primitive } from "radix-ui";
28import { type ReactNode, useEffect, useRef, useState } from "react";
29import { Link, useLocation, useNavigate, useSubmit } from "react-router";
30
31import { type Abilities, type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts";
32
33import { Avatar } from "./ui";
34import { TabStrip } from "./ui/tab-strip";
35import { StatusDialog } from "./presence";
36import { setPresence, useOwnPresence } from "../lib/notify-client";
37import { dndOn, liveStatus, pauseUntil, untilLabel } from "../lib/presence";
38import { STATUS_URL } from "../lib/status";
39import { modeOf } from "../lib/workspace-nav";
40import { PROJECT_PAGE_LINKS, type ProjectPage, projectPageAt, projectPages } from "../lib/chrome";
41import { ROADMAP, type RoadmapItem } from "../lib/roadmap";
42
43/**
44 * The phone's layout (below 768px): a tab bar along the bottom, the
45 * workspace's avatar opening a sheet for everything else, and each tab a
46 * list that pushes its detail full screen. Desktop keeps the rail.
47 */
48
49/** Whether a path is one conversation, which takes the whole screen on a phone. */
50export function isConversation(pathname: string): boolean {
51 return /^\/[^/]+\/-\/chat\/(?!browse\/?$)[^/]+/.test(pathname);
52}
53
54/**
55 * Follows the visual viewport, which shrinks when the keyboard opens: its
56 * height and top go on the page as `--vv-height` and `--vv-top`, what
57 * the keyboard covers as `--keyboard-inset`, and
58 * `data-keyboard="open"` on <html> while the keyboard is up, so a
59 * composer can sit just above it and the tab bar can step aside.
60 */
61export function useVisualViewport() {
62 useEffect(() => {
63 const viewport = window.visualViewport;
64 if (!viewport) return;
65 const root = document.documentElement;
66 const update = () => {
67 root.style.setProperty("--vv-height", `${viewport.height}px`);
68 root.style.setProperty("--vv-top", `${viewport.offsetTop}px`);
69 // How much of the bottom the keyboard covers, for a sheet to sit on it.
70 root.style.setProperty("--keyboard-inset", `${Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop)}px`);
71 const keyboard = window.innerHeight - viewport.height > 140;
72 if (keyboard) root.dataset.keyboard = "open";
73 else delete root.dataset.keyboard;
74 };
75 update();
76 viewport.addEventListener("resize", update);
77 viewport.addEventListener("scroll", update);
78 return () => {
79 viewport.removeEventListener("resize", update);
80 viewport.removeEventListener("scroll", update);
81 };
82 }, []);
83}
84
85/**
86 * Swipe from the left edge to go back, as on a phone's own apps: a touch
87 * that starts within 28px of the edge and travels 80px right, more across
88 * than down. Going back is the browser's own back, so history stays true.
89 */
90export function useSwipeBack(onBack: () => void) {
91 const start = useRef<{ x: number; y: number } | null>(null);
92 return {
93 onTouchStart: (event: React.TouchEvent) => {
94 const touch = event.touches[0];
95 start.current = touch && touch.clientX < 28 ? { x: touch.clientX, y: touch.clientY } : null;
96 },
97 onTouchMove: (event: React.TouchEvent) => {
98 const touch = event.touches[0];
99 if (!start.current || !touch) return;
100 const dx = touch.clientX - start.current.x;
101 const dy = Math.abs(touch.clientY - start.current.y);
102 if (dx > 80 && dx > dy * 1.5) {
103 start.current = null;
104 onBack();
105 }
106 },
107 onTouchEnd: () => {
108 start.current = null;
109 },
110 };
111}
112
113/** Back, as the browser would; to `fallback` when this page was the first one opened. */
114export function useBack(fallback: string) {
115 const navigate = useNavigate();
116 return () => {
117 const index = (window.history.state as { idx?: number } | null)?.idx ?? 0;
118 if (index > 0) navigate(-1);
119 else navigate(fallback, { replace: true });
120 };
121}
122
123type Tab = { key: string; label: string; to: string; icon: ReactNode; badge?: { count: number; loud: boolean } };
124
125/**
126 * The tab bar along the bottom of a phone: Home, Code (Docs, for a member
127 * without Code), Chat, Agents and the Inbox, each with what is unread. It
128 * steps aside while the keyboard is up and inside a conversation. The tab
129 * you are in, tapped again, opens its mode's menu.
130 */
131export function MobileTabBar({
132 workspace,
133 unread,
134 onReselect,
135}: {
136 workspace: Membership;
137 unread: { inbox: number; chat: number; mentions: number };
138 /** Tapping the tab you are already in: the shell opens that mode's menu (its sidebar). */
139 onReselect?: () => void;
140}) {
141 const { pathname } = useLocation();
142 const slug = workspace.slug;
143 const code = hasCodeAccess(workspace);
144 const mode = modeOf(pathname, slug);
145 const tabs: Tab[] = [
146 { key: "home", label: "Home", to: `/${slug}/-/home`, icon: <House size={21} /> },
147 code
148 ? { key: "code", label: "Code", to: `/${slug}/-/projects`, icon: <Code2 size={21} /> }
149 : { key: "docs", label: "Docs", to: `/${slug}/-/docs`, icon: <BookOpen size={21} /> },
150 {
151 key: "chat",
152 label: "Chat",
153 to: `/${slug}/-/chat`,
154 icon: <MessagesSquare size={21} />,
155 badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 },
156 },
157 { key: "agents", label: "Agents", to: `/${slug}/-/agents`, icon: <Sparkles size={21} /> },
158 { key: "inbox", label: "Inbox", to: "/inbox", icon: <Inbox size={21} />, badge: { count: unread.inbox, loud: true } },
159 ];
160 if (isConversation(pathname)) return null;
161 return (
162 <nav
163 aria-label="Tabs"
164 className="fixed inset-x-0 bottom-0 z-40 border-t border-line bg-[#0b0b0d]/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden in-data-[keyboard=open]:hidden"
165 >
166 <ul className="grid h-14 grid-cols-5">
167 {tabs.map((tab) => {
168 const current = mode === tab.key;
169 const count = tab.badge?.count ?? 0;
170 return (
171 <li key={tab.key}>
172 <Link
173 to={tab.to}
174 prefetch="intent"
175 aria-current={current ? "page" : undefined}
176 aria-label={count > 0 ? `${tab.label}, ${count} unread` : tab.label}
177 onClick={(event) => {
178 // The tab you are in, tapped again: its menu, as a phone's own apps do.
179 // Chat's first page on a phone is its sidebar already.
180 if (!current || !onReselect || (tab.key === "chat" && pathname.replace(/\/$/, "") === tab.to)) return;
181 event.preventDefault();
182 onReselect();
183 }}
184 className="flex h-full flex-col items-center justify-center gap-0.5 transition-colors active:bg-raised/60"
185 >
186 <span className={`relative flex h-7 w-12 items-center justify-center rounded-full transition-colors ${current ? "bg-[#2c2c33] text-fg" : "text-muted"}`}>
187 {tab.icon}
188 {count > 0 && (
189 <span
190 className={`absolute -top-1 right-0.5 min-w-[1.125rem] rounded-full px-1 text-center text-[0.625rem] leading-[1.125rem] font-bold tabular-nums ring-2 ring-[#0b0b0d] ${
191 tab.badge?.loud ? "bg-accent text-bg" : "bg-[#3d3d45] text-fg"
192 }`}
193 >
194 {count > 99 ? "99+" : count}
195 </span>
196 )}
197 </span>
198 <span className={`text-[0.6875rem] font-medium ${current ? "text-fg" : "text-faint"}`}>{tab.label}</span>
199 </Link>
200 </li>
201 );
202 })}
203 </ul>
204 </nav>
205 );
206}
207
208/**
209 * A sheet that rises from the bottom of a phone, with a handle to drag it
210 * back down. Escape, a tap outside or a drag of more than 90px closes it.
211 */
212export function BottomSheet({
213 open,
214 onOpenChange,
215 title,
216 children,
217}: {
218 open: boolean;
219 onOpenChange: (open: boolean) => void;
220 title: string;
221 children: ReactNode;
222}) {
223 const [drag, setDrag] = useState(0);
224 const from = useRef<number | null>(null);
225 useEffect(() => {
226 if (!open) setDrag(0);
227 }, [open]);
228 const handlers = {
229 onPointerDown: (event: React.PointerEvent) => {
230 from.current = event.clientY;
231 (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
232 },
233 onPointerMove: (event: React.PointerEvent) => {
234 if (from.current == null) return;
235 setDrag(Math.max(0, event.clientY - from.current));
236 },
237 onPointerUp: () => {
238 if (drag > 90) onOpenChange(false);
239 else setDrag(0);
240 from.current = null;
241 },
242 };
243 return (
244 <Primitive.Root open={open} onOpenChange={onOpenChange}>
245 <Primitive.Portal>
246 <Primitive.Overlay className="fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out" />
247 <Primitive.Content
248 aria-describedby={undefined}
249 onOpenAutoFocus={(event) => event.preventDefault()}
250 style={{ transform: drag ? `translateY(${drag}px)` : undefined, transition: from.current == null ? "transform 0.2s ease-out" : "none" }}
251 className="fixed inset-x-0 bottom-0 z-50 flex max-h-[88dvh] flex-col rounded-t-2xl border-t border-line-strong 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)]"
252 >
253 <div {...handlers} className="flex shrink-0 cursor-grab touch-none justify-center pt-2.5 pb-2" aria-hidden="true">
254 <span className="h-1.5 w-10 rounded-full bg-line-strong" />
255 </div>
256 <Primitive.Title className="sr-only">{title}</Primitive.Title>
257 <div className="min-h-0 grow overflow-y-auto overscroll-contain px-3 pb-3">{children}</div>
258 </Primitive.Content>
259 </Primitive.Portal>
260 </Primitive.Root>
261 );
262}
263
264/** A row in a bottom sheet: 48px tall, an icon, words, and what sits at its end. */
265export function SheetRow({ to, href, icon, children, end, onClick }: { to?: string; href?: string; icon: ReactNode; children: ReactNode; end?: ReactNode; onClick?: () => void }) {
266 const className = "flex h-12 w-full items-center gap-3 rounded-xl px-3 text-left text-[0.9375rem] text-fg-soft transition-colors active:bg-raised";
267 const inner = (
268 <>
269 <span className="flex w-6 shrink-0 justify-center text-muted [&_svg]:size-5">{icon}</span>
270 <span className="min-w-0 grow truncate">{children}</span>
271 {end}
272 </>
273 );
274 if (to) {
275 return (
276 <Link to={to} className={className} onClick={onClick}>
277 {inner}
278 </Link>
279 );
280 }
281 if (href) {
282 return (
283 <a href={href} className={className}>
284 {inner}
285 </a>
286 );
287 }
288 return (
289 <button type="button" onClick={onClick} className={className}>
290 {inner}
291 </button>
292 );
293}
294
295function SheetGroup({ title, children }: { title: string; children: ReactNode }) {
296 return (
297 <section className="mt-2 border-t border-line pt-2">
298 <h3 className="px-3 pt-1 pb-1 text-xs font-medium text-faint">{title}</h3>
299 {children}
300 </section>
301 );
302}
303
304function displayName(membership: Membership): string {
305 return membership.name?.trim() || membership.slug;
306}
307
308/** Your status, away and pausing notifications, as the account menu has them on a computer. */
309function OwnPresenceRows({ onEdit }: { onEdit: () => void }) {
310 const me = useOwnPresence();
311 const now = Date.now();
312 const status = liveStatus(me?.status, now);
313 const paused = dndOn(me, now);
314 const away = me?.away_manual === true;
315 return (
316 <>
317 <SheetRow icon={status?.emoji ? <span className="text-lg leading-none">{status.emoji}</span> : <Smile />} onClick={onEdit}>
318 {status ? status.text || "Status set" : "Set a status"}
319 </SheetRow>
320 <SheetRow icon={away ? <Sun /> : <Moon />} onClick={() => void setPresence({ away: !away }, { away_manual: !away, presence: away ? "active" : "away" })}>
321 {away ? "Set yourself active" : "Set yourself away"}
322 </SheetRow>
323 {paused ? (
324 <SheetRow
325 icon={<BellRing />}
326 end={me?.dnd_until ? <span className="text-xs text-faint">{untilLabel(me.dnd_until, new Date(now))}</span> : null}
327 onClick={() => void setPresence({ dnd_until: null }, { dnd_until: null })}
328 >
329 Resume notifications
330 </SheetRow>
331 ) : (
332 <SheetRow
333 icon={<BellOff />}
334 onClick={() => {
335 const until = pauseUntil("1h", new Date());
336 void setPresence({ dnd_until: until }, { dnd_until: until });
337 }}
338 >
339 Pause notifications for 1 hour
340 </SheetRow>
341 )}
342 </>
343 );
344}
345
346/**
347 * The phone's everything-else: the workspace avatar at the top left opens
348 * it. Docs and the workspace's own pages first, then your workspaces,
349 * help, and your account. The mode you are in has its own menu (its
350 * sidebar), behind the button beside the avatar.
351 */
352export function AvatarSheetButton({ user, workspace }: { user: User; workspace: Membership }) {
353 const [open, setOpen] = useState(false);
354 const { pathname } = useLocation();
355 const submit = useSubmit();
356 useEffect(() => setOpen(false), [pathname]);
357 const slug = workspace.slug;
358 const owner = workspace.role === "owner";
359 const [editing, setEditing] = useState(false);
360 return (
361 <>
362 <button
363 type="button"
364 aria-label={`${displayName(workspace)}: workspaces, settings and account`}
365 onClick={() => setOpen(true)}
366 className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform active:scale-95 md:hidden"
367 >
368 <Avatar name={slug} image={workspace.avatar} size={32} square />
369 </button>
370 <BottomSheet open={open} onOpenChange={setOpen} title="Workspaces, settings and account">
371 <div className="flex items-center gap-3 px-3 pt-1 pb-3">
372 <Avatar name={slug} image={workspace.avatar} size={44} square />
373 <div className="min-w-0">
374 <p className="truncate text-base font-semibold">{displayName(workspace)}</p>
375 <p className="truncate font-mono text-xs text-muted">g1t.sh/{slug}</p>
376 </div>
377 </div>
378 <SheetGroup title="Workspace">
379 {/* Docs has no tab of its own for someone with Code: it leads here. */}
380 {hasCodeAccess(workspace) && (
381 <SheetRow to={`/${slug}/-/docs`} icon={<BookOpen />} end={<ChevronRight size={16} className="text-faint" />}>
382 Docs
383 </SheetRow>
384 )}
385 <SheetRow to={`/${slug}/-/workspace`} icon={<Building2 />}>
386 Overview
387 </SheetRow>
388 <SheetRow to={`/${slug}/-/people`} icon={<Users />}>
389 People
390 </SheetRow>
391 <SheetRow to={`/${slug}/-/teams`} icon={<UsersRound />}>
392 Teams
393 </SheetRow>
394 <SheetRow to={`/${slug}/-/usage`} icon={<BarChart3 />}>
395 Usage and billing
396 </SheetRow>
397 <SheetRow to={`/${slug}/-/integrations`} icon={<Plug />}>
398 Integrations
399 </SheetRow>
400 <SheetRow to={owner ? `/${slug}/-/settings` : `/${slug}/-/repositories`} icon={<Settings />}>
401 Settings
402 </SheetRow>
403 </SheetGroup>
404 <SheetGroup title="Switch workspace">
405 {(user.workspaces ?? []).map((membership) => (
406 <SheetRow
407 key={membership.slug}
408 to={`/${membership.slug}/-/home`}
409 icon={<Avatar name={membership.slug} image={membership.avatar} size={24} square />}
410 end={membership.slug === slug ? <Check size={18} className="text-accent" /> : null}
411 >
412 {displayName(membership)}
413 </SheetRow>
414 ))}
415 <SheetRow to="/workspaces/new" icon={<Plus />}>
416 New workspace
417 </SheetRow>
418 </SheetGroup>
419 <SheetGroup title="Help">
420 <SheetRow to="/support" icon={<LifeBuoy />}>
421 Support
422 </SheetRow>
423 <SheetRow href="https://docs.g1t.sh/" icon={<BookOpen />}>
424 Documentation
425 </SheetRow>
426 <SheetRow href={STATUS_URL} icon={<Activity />}>
427 Status
428 </SheetRow>
429 </SheetGroup>
430 <SheetGroup title={`@${shownUsername(user)}`}>
431 <OwnPresenceRows
432 onEdit={() => {
433 setOpen(false);
434 setEditing(true);
435 }}
436 />
437 <SheetRow to={`/u/${user.username}`} icon={<CircleUserRound />}>
438 Your profile
439 </SheetRow>
440 <SheetRow to="/settings" icon={<Settings />}>
441 Your settings
442 </SheetRow>
443 <SheetRow icon={<LogOut />} onClick={() => submit(null, { method: "post", action: "/logout" })}>
444 Sign out
445 </SheetRow>
446 </SheetGroup>
447 </BottomSheet>
448 <StatusDialog open={editing} onOpenChange={setEditing} />
449 </>
450 );
451}
452
453/** Which project page a roadmap page (`soon/<key>`) sits under, by its section. */
454const SOON_UNDER: Partial<Record<RoadmapItem["section"], ProjectPage>> = {
455 Code: "code",
456 Issues: "issues",
457 Agents: "agents",
458 Deployments: "deployments",
459 Observability: "observability",
460 Security: "security",
461 Insights: "insights",
462};
463const SOON_PAGES: Record<string, ProjectPage> = Object.fromEntries(
464 ROADMAP.flatMap((item) => (SOON_UNDER[item.section] ? [[item.key, SOON_UNDER[item.section]!] as const] : [])),
465);
466
467/**
468 * A project's pages on a phone, under its name: the sidebar's own list
469 * (lib/chrome.ts) as a row of tabs that scrolls sideways, the current one
470 * kept in view, so Issues and Pull requests are one tap away. From 768px
471 * the sidebar has them.
472 */
473export function ProjectStrip({
474 base,
475 member,
476 can,
477 settings,
478 counts,
479}: {
480 /** `/<namespace>/<name>`. */
481 base: string;
482 member: boolean;
483 can?: Partial<Abilities>;
484 /** Whether they see its settings (lib/access.ts `seesSettings`), as the sidebar decides. */
485 settings?: boolean;
486 counts?: { issues: number; pulls: number };
487}) {
488 const { pathname } = useLocation();
489 const rest = pathname.toLowerCase().startsWith(base.toLowerCase()) ? pathname.slice(base.length) : "";
490 const at = projectPageAt(rest, SOON_PAGES);
491 const pages: (ProjectPage | "overview")[] = ["overview", ...projectPages(member, can).filter((page) => page !== "observability" && page !== "settings"),
492 ...(settings ?? member ? (["settings"] as const) : []),
493 ];
494 const pill = (current: boolean) =>
495 `flex h-9 items-center gap-1.5 rounded-full px-3.5 text-sm transition-colors ${
496 current ? "bg-raised font-medium text-fg ring-1 ring-line-strong" : "text-muted active:bg-raised/60"
497 }`;
498 return (
499 <TabStrip label="Project" className="-mx-4 gap-1 px-4 md:hidden">
500 {pages.map((page) => {
501 const current = at === page;
502 const link = page === "overview" ? { label: "Overview", path: "" } : PROJECT_PAGE_LINKS[page];
503 const count = page === "issues" ? counts?.issues : page === "pulls" ? counts?.pulls : undefined;
504 return (
505 <Link
506 key={page}
507 to={link.path ? `${base}/${link.path}` : base}
508 prefetch="intent"
509 aria-current={current ? "page" : undefined}
510 data-active={current || undefined}
511 className={pill(current)}
512 >
513 {link.label}
514 {count != null && count > 0 && <span className="rounded-full bg-line px-1.5 text-xs tabular-nums text-muted">{count}</span>}
515 </Link>
516 );
517 })}
518 </TabStrip>
519 );
520}