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