Skip to content
369 linesCodeBlameRaw
1import {
2 Activity,
3 BarChart3,
4 BookOpen,
5 Building2,
6 Check,
7 CircleUserRound,
8 Code2,
9 House,
10 Inbox,
11 LifeBuoy,
12 LogOut,
13 MessagesSquare,
14 Plug,
15 Plus,
16 Settings,
17 Sparkles,
18 Users,
19 UsersRound,
20} from "lucide-react";
21import { Dialog as Primitive } from "radix-ui";
22import { type ReactNode, useEffect, useRef, useState } from "react";
23import { Link, useLocation, useNavigate, useSubmit } from "react-router";
24
25import { type Membership, type User, hasCodeAccess } from "@g1t/contracts";
26
27import { Avatar } from "./ui";
28import { STATUS_URL } from "../lib/status";
29import { modeOf } from "../lib/workspace-nav";
30
31/**
32 * The phone's layout (below 768px): a tab bar along the bottom, the
33 * workspace's avatar opening a sheet for everything else, and each tab a
34 * list that pushes its detail full screen. Desktop keeps the rail.
35 */
36
37/** Whether a path is one conversation, which takes the whole screen on a phone. */
38export function isConversation(pathname: string): boolean {
39 return /^\/[^/]+\/-\/chat\/(?!browse\/?$)[^/]+/.test(pathname);
40}
41
42/**
43 * Follows the visual viewport, which shrinks when the keyboard opens: its
44 * height and top go on the page as `--vv-height` and `--vv-top`, and
45 * `data-keyboard="open"` on <html> while the keyboard is up, so a
46 * composer can sit just above it and the tab bar can step aside.
47 */
48export function useVisualViewport() {
49 useEffect(() => {
50 const viewport = window.visualViewport;
51 if (!viewport) return;
52 const root = document.documentElement;
53 const update = () => {
54 root.style.setProperty("--vv-height", `${viewport.height}px`);
55 root.style.setProperty("--vv-top", `${viewport.offsetTop}px`);
56 const keyboard = window.innerHeight - viewport.height > 140;
57 if (keyboard) root.dataset.keyboard = "open";
58 else delete root.dataset.keyboard;
59 };
60 update();
61 viewport.addEventListener("resize", update);
62 viewport.addEventListener("scroll", update);
63 return () => {
64 viewport.removeEventListener("resize", update);
65 viewport.removeEventListener("scroll", update);
66 };
67 }, []);
68}
69
70/**
71 * Swipe from the left edge to go back, as on a phone's own apps: a touch
72 * that starts within 28px of the edge and travels 80px right, more across
73 * than down. Going back is the browser's own back, so history stays true.
74 */
75export function useSwipeBack(onBack: () => void) {
76 const start = useRef<{ x: number; y: number } | null>(null);
77 return {
78 onTouchStart: (event: React.TouchEvent) => {
79 const touch = event.touches[0];
80 start.current = touch && touch.clientX < 28 ? { x: touch.clientX, y: touch.clientY } : null;
81 },
82 onTouchMove: (event: React.TouchEvent) => {
83 const touch = event.touches[0];
84 if (!start.current || !touch) return;
85 const dx = touch.clientX - start.current.x;
86 const dy = Math.abs(touch.clientY - start.current.y);
87 if (dx > 80 && dx > dy * 1.5) {
88 start.current = null;
89 onBack();
90 }
91 },
92 onTouchEnd: () => {
93 start.current = null;
94 },
95 };
96}
97
98/** Back, as the browser would; to `fallback` when this page was the first one opened. */
99export function useBack(fallback: string) {
100 const navigate = useNavigate();
101 return () => {
102 const index = (window.history.state as { idx?: number } | null)?.idx ?? 0;
103 if (index > 0) navigate(-1);
104 else navigate(fallback, { replace: true });
105 };
106}
107
108type Tab = { key: string; label: string; to: string; icon: ReactNode; badge?: { count: number; loud: boolean } };
109
110/**
111 * The tab bar along the bottom of a phone: Home, Code (Docs, for a member
112 * without Code), Chat, Agents and the Inbox, each with what is unread. It
113 * steps aside while the keyboard is up and inside a conversation.
114 */
115export function MobileTabBar({ workspace, unread }: { workspace: Membership; unread: { inbox: number; chat: number; mentions: number } }) {
116 const { pathname } = useLocation();
117 const slug = workspace.slug;
118 const code = hasCodeAccess(workspace);
119 const mode = modeOf(pathname, slug);
120 const tabs: Tab[] = [
121 { key: "home", label: "Home", to: `/${slug}/-/home`, icon: <House size={21} /> },
122 code
123 ? { key: "code", label: "Code", to: `/${slug}/-/projects`, icon: <Code2 size={21} /> }
124 : { key: "docs", label: "Docs", to: `/${slug}/-/docs`, icon: <BookOpen size={21} /> },
125 {
126 key: "chat",
127 label: "Chat",
128 to: `/${slug}/-/chat`,
129 icon: <MessagesSquare size={21} />,
130 badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 },
131 },
132 { key: "agents", label: "Agents", to: `/${slug}/-/agents`, icon: <Sparkles size={21} /> },
133 { key: "inbox", label: "Inbox", to: "/inbox", icon: <Inbox size={21} />, badge: { count: unread.inbox, loud: true } },
134 ];
135 if (isConversation(pathname)) return null;
136 return (
137 <nav
138 aria-label="Tabs"
139 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"
140 >
141 <ul className="grid h-14 grid-cols-5">
142 {tabs.map((tab) => {
143 const current = mode === tab.key;
144 const count = tab.badge?.count ?? 0;
145 return (
146 <li key={tab.key}>
147 <Link
148 to={tab.to}
149 prefetch="intent"
150 aria-current={current ? "page" : undefined}
151 aria-label={count > 0 ? `${tab.label}, ${count} unread` : tab.label}
152 className="flex h-full flex-col items-center justify-center gap-0.5 transition-colors active:bg-raised/60"
153 >
154 <span className={`relative flex h-7 w-12 items-center justify-center rounded-full transition-colors ${current ? "bg-[#2c2c33] text-fg" : "text-muted"}`}>
155 {tab.icon}
156 {count > 0 && (
157 <span
158 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] ${
159 tab.badge?.loud ? "bg-accent text-bg" : "bg-[#3d3d45] text-fg"
160 }`}
161 >
162 {count > 99 ? "99+" : count}
163 </span>
164 )}
165 </span>
166 <span className={`text-[0.6875rem] font-medium ${current ? "text-fg" : "text-faint"}`}>{tab.label}</span>
167 </Link>
168 </li>
169 );
170 })}
171 </ul>
172 </nav>
173 );
174}
175
176/**
177 * A sheet that rises from the bottom of a phone, with a handle to drag it
178 * back down. Escape, a tap outside or a drag of more than 90px closes it.
179 */
180export function BottomSheet({
181 open,
182 onOpenChange,
183 title,
184 children,
185}: {
186 open: boolean;
187 onOpenChange: (open: boolean) => void;
188 title: string;
189 children: ReactNode;
190}) {
191 const [drag, setDrag] = useState(0);
192 const from = useRef<number | null>(null);
193 useEffect(() => {
194 if (!open) setDrag(0);
195 }, [open]);
196 const handlers = {
197 onPointerDown: (event: React.PointerEvent) => {
198 from.current = event.clientY;
199 (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
200 },
201 onPointerMove: (event: React.PointerEvent) => {
202 if (from.current == null) return;
203 setDrag(Math.max(0, event.clientY - from.current));
204 },
205 onPointerUp: () => {
206 if (drag > 90) onOpenChange(false);
207 else setDrag(0);
208 from.current = null;
209 },
210 };
211 return (
212 <Primitive.Root open={open} onOpenChange={onOpenChange}>
213 <Primitive.Portal>
214 <Primitive.Overlay className="fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out" />
215 <Primitive.Content
216 aria-describedby={undefined}
217 onOpenAutoFocus={(event) => event.preventDefault()}
218 style={{ transform: drag ? `translateY(${drag}px)` : undefined, transition: from.current == null ? "transform 0.2s ease-out" : "none" }}
219 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)]"
220 >
221 <div {...handlers} className="flex shrink-0 cursor-grab touch-none justify-center pt-2.5 pb-2" aria-hidden="true">
222 <span className="h-1.5 w-10 rounded-full bg-line-strong" />
223 </div>
224 <Primitive.Title className="sr-only">{title}</Primitive.Title>
225 <div className="min-h-0 grow overflow-y-auto overscroll-contain px-3 pb-3">{children}</div>
226 </Primitive.Content>
227 </Primitive.Portal>
228 </Primitive.Root>
229 );
230}
231
232/** A row in a bottom sheet: 48px tall, an icon, words, and what sits at its end. */
233export function SheetRow({ to, href, icon, children, end, onClick }: { to?: string; href?: string; icon: ReactNode; children: ReactNode; end?: ReactNode; onClick?: () => void }) {
234 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";
235 const inner = (
236 <>
237 <span className="flex w-6 shrink-0 justify-center text-muted [&_svg]:size-5">{icon}</span>
238 <span className="min-w-0 grow truncate">{children}</span>
239 {end}
240 </>
241 );
242 if (to) {
243 return (
244 <Link to={to} className={className} onClick={onClick}>
245 {inner}
246 </Link>
247 );
248 }
249 if (href) {
250 return (
251 <a href={href} className={className}>
252 {inner}
253 </a>
254 );
255 }
256 return (
257 <button type="button" onClick={onClick} className={className}>
258 {inner}
259 </button>
260 );
261}
262
263function SheetGroup({ title, children }: { title: string; children: ReactNode }) {
264 return (
265 <section className="mt-2 border-t border-line pt-2">
266 <h3 className="px-3 pt-1 pb-1 text-xs font-medium text-faint">{title}</h3>
267 {children}
268 </section>
269 );
270}
271
272function displayName(membership: Membership): string {
273 return membership.name?.trim() || membership.slug;
274}
275
276/**
277 * The phone's everything-else: the workspace avatar at the top left opens
278 * it. Your workspaces, Docs, the workspace's own pages, help, and your
279 * account. It replaces the menu sheet below 768px.
280 */
281export function AvatarSheetButton({ user, workspace }: { user: User; workspace: Membership }) {
282 const [open, setOpen] = useState(false);
283 const { pathname } = useLocation();
284 const submit = useSubmit();
285 useEffect(() => setOpen(false), [pathname]);
286 const slug = workspace.slug;
287 const owner = workspace.role === "owner";
288 return (
289 <>
290 <button
291 type="button"
292 aria-label={`${displayName(workspace)}: workspaces, settings and account`}
293 onClick={() => setOpen(true)}
294 className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform active:scale-95 md:hidden"
295 >
296 <Avatar name={slug} image={workspace.avatar} size={32} square />
297 </button>
298 <BottomSheet open={open} onOpenChange={setOpen} title="Workspaces, settings and account">
299 <div className="flex items-center gap-3 px-3 pt-1 pb-3">
300 <Avatar name={slug} image={workspace.avatar} size={44} square />
301 <div className="min-w-0">
302 <p className="truncate text-base font-semibold">{displayName(workspace)}</p>
303 <p className="truncate font-mono text-xs text-muted">g1t.sh/{slug}</p>
304 </div>
305 </div>
306 <SheetGroup title="Switch workspace">
307 {(user.workspaces ?? []).map((membership) => (
308 <SheetRow
309 key={membership.slug}
310 to={`/${membership.slug}/-/home`}
311 icon={<Avatar name={membership.slug} image={membership.avatar} size={24} square />}
312 end={membership.slug === slug ? <Check size={18} className="text-accent" /> : null}
313 >
314 {displayName(membership)}
315 </SheetRow>
316 ))}
317 <SheetRow to="/workspaces/new" icon={<Plus />}>
318 New workspace
319 </SheetRow>
320 </SheetGroup>
321 <SheetGroup title="Workspace">
322 <SheetRow to={`/${slug}/-/docs`} icon={<BookOpen />}>
323 Docs
324 </SheetRow>
325 <SheetRow to={`/${slug}/-/workspace`} icon={<Building2 />}>
326 Overview
327 </SheetRow>
328 <SheetRow to={`/${slug}/-/people`} icon={<Users />}>
329 People
330 </SheetRow>
331 <SheetRow to={`/${slug}/-/teams`} icon={<UsersRound />}>
332 Teams
333 </SheetRow>
334 <SheetRow to={`/${slug}/-/usage`} icon={<BarChart3 />}>
335 Usage and billing
336 </SheetRow>
337 <SheetRow to={`/${slug}/-/integrations`} icon={<Plug />}>
338 Integrations
339 </SheetRow>
340 <SheetRow to={owner ? `/${slug}/-/settings` : `/${slug}/-/repositories`} icon={<Settings />}>
341 Settings
342 </SheetRow>
343 </SheetGroup>
344 <SheetGroup title="Help">
345 <SheetRow to="/support" icon={<LifeBuoy />}>
346 Support
347 </SheetRow>
348 <SheetRow href="https://docs.g1t.sh/" icon={<BookOpen />}>
349 Documentation
350 </SheetRow>
351 <SheetRow href={STATUS_URL} icon={<Activity />}>
352 Status
353 </SheetRow>
354 </SheetGroup>
355 <SheetGroup title={`@${user.username}`}>
356 <SheetRow to={`/u/${user.username}`} icon={<CircleUserRound />}>
357 Your profile
358 </SheetRow>
359 <SheetRow to="/settings" icon={<Settings />}>
360 Your settings
361 </SheetRow>
362 <SheetRow icon={<LogOut />} onClick={() => submit(null, { method: "post", action: "/logout" })}>
363 Sign out
364 </SheetRow>
365 </SheetGroup>
366 </BottomSheet>
367 </>
368 );
369}