Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1 | import { |
| 2 | Activity, | |
| 3 | BarChart3, | |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 4 | BellOff, |
| 5 | BellRing, | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 6 | BookOpen, |
| 7 | Building2, | |
| 8 | Check, | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 9 | ChevronRight, |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 10 | CircleUserRound, |
| 11 | Code2, | |
| 12 | House, | |
| 13 | Inbox, | |
| 14 | LifeBuoy, | |
| 15 | LogOut, | |
| 16 | MessagesSquare, | |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 17 | Moon, |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 18 | Plug, |
| 19 | Plus, | |
| 20 | Settings, | |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 21 | Smile, |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 22 | Sparkles, |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 23 | Sun, |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 24 | Users, |
| 25 | UsersRound, | |
| 26 | } from "lucide-react"; | |
| 27 | import { Dialog as Primitive } from "radix-ui"; | |
| 28 | import { type ReactNode, useEffect, useRef, useState } from "react"; | |
| 29 | import { Link, useLocation, useNavigate, useSubmit } from "react-router"; | |
| 30 | ||
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 31 | import { type Abilities, type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts"; |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 32 | |
| 33 | import { Avatar } from "./ui"; | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 34 | import { TabStrip } from "./ui/tab-strip"; |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 35 | import { StatusDialog } from "./presence"; |
| 36 | import { setPresence, useOwnPresence } from "../lib/notify-client"; | |
| 37 | import { dndOn, liveStatus, pauseUntil, untilLabel } from "../lib/presence"; | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 38 | import { STATUS_URL } from "../lib/status"; |
| 39 | import { modeOf } from "../lib/workspace-nav"; | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 40 | import { PROJECT_PAGE_LINKS, type ProjectPage, projectPageAt, projectPages } from "../lib/chrome"; |
| 41 | import { ROADMAP, type RoadmapItem } from "../lib/roadmap"; | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 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. */ | |
| 50 | export 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 | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 56 | * height and top go on the page as `--vv-height` and `--vv-top`, what |
| 57 | * the keyboard covers as `--keyboard-inset`, and | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 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 | */ | |
| 61 | export 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`); | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 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`); | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 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 | */ | |
| 90 | export 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. */ | |
| 114 | export 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 | ||
| 123 | type Tab = { key: string; label: string; to: string; icon: ReactNode; badge?: { count: number; loud: boolean } }; | |
| 124 | ||
| 125 | /** | |
| Code sits second in the rail and on a phone's tab bar: for members who have it, it is the everyday mode | 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 | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 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. | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 130 | */ |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 131 | export 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 | }) { | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 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} /> }, | |
| Code sits second in the rail and on a phone's tab bar: for members who have it, it is the everyday mode | 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} /> }, | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 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} | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 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 | }} | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 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 | */ | |
| 212 | export 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. */ | |
| 265 | export 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 | ||
| 295 | function 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 | ||
| 304 | function displayName(membership: Membership): string { | |
| 305 | return membership.name?.trim() || membership.slug; | |
| 306 | } | |
| 307 | ||
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 308 | /** Your status, away and pausing notifications, as the account menu has them on a computer. */ |
| 309 | function 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 | ||
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 346 | /** |
| 347 | * The phone's everything-else: the workspace avatar at the top left opens | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 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. | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 351 | */ |
| 352 | export 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"; | |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 359 | const [editing, setEditing] = useState(false); |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 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> | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 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 | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 383 | </SheetRow> |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 384 | )} |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 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> | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 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> | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 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> | |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 430 | <SheetGroup title={`@${shownUsername(user)}`}> |
| 431 | <OwnPresenceRows | |
| 432 | onEdit={() => { | |
| 433 | setOpen(false); | |
| 434 | setEditing(true); | |
| 435 | }} | |
| 436 | /> | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 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> | |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 448 | <StatusDialog open={editing} onOpenChange={setEditing} /> |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 449 | </> |
| 450 | ); | |
| 451 | } | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 452 | |
| 453 | /** Which project page a roadmap page (`soon/<key>`) sits under, by its section. */ | |
| 454 | const 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 | }; | |
| 463 | const 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 | */ | |
| 473 | export 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.