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.
| g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them. | 1 | import { Check, ChevronDown, CircleUserRound, LayoutGrid, Plus, Settings } from "lucide-react"; |
| 2 | import { type ReactNode, useState } from "react"; | |
| 3 | import { Link, useLocation, useNavigation } from "react-router"; | |
| 4 | ||
| 5 | import { type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts"; | |
| 6 | ||
| 7 | import { AppsLauncher, appIcon, useAppPins } from "./apps"; | |
| 8 | import { Mark } from "./logo"; | |
| 9 | import { Avatar } from "./ui"; | |
| 10 | import { Hint } from "./ui/hint"; | |
| 11 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"; | |
| 12 | import { | |
| 13 | DropdownMenu, | |
| 14 | DropdownMenuContent, | |
| 15 | DropdownMenuItem, | |
| 16 | DropdownMenuLabel, | |
| 17 | DropdownMenuSeparator, | |
| 18 | DropdownMenuTrigger, | |
| 19 | } from "./ui/dropdown-menu"; | |
| 20 | import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; | |
| 21 | import { type AppKey, type PinnableApp, appOf } from "../lib/apps"; | |
| 22 | import { cn } from "../lib/cn"; | |
| 23 | import { paletteKeyLabel } from "../lib/shortcut"; | |
| 24 | import { type ModeKey, modeHome, modeOf, todayPath } from "../lib/workspace-nav"; | |
| 25 | ||
| 26 | /** The dock's column: 8px from the window's edge, 80px wide. */ | |
| 27 | export const DOCK_COLUMN = "5.5rem"; | |
| 28 | ||
| 29 | type Item = { key: ModeKey; label: string; badge?: { count: number; loud: boolean } | null }; | |
| 30 | ||
| 31 | /** A count on an icon: loud (the accent) for what waits on you, quiet for the rest. */ | |
| 32 | export function CountBadge({ count, loud, className }: { count: number; loud: boolean; className?: string }) { | |
| 33 | if (count <= 0) return null; | |
| 34 | return ( | |
| 35 | <span | |
| 36 | className={cn( | |
| 37 | "absolute -top-1 -right-1.5 min-w-[1.125rem] rounded-full px-1 text-center text-[0.625rem] leading-[1.125rem] font-bold tabular-nums ring-2 ring-dock", | |
| 38 | loud ? "bg-accent text-bg" : "bg-line-strong text-fg", | |
| 39 | className, | |
| 40 | )} | |
| 41 | > | |
| 42 | {count > 99 ? "99+" : count} | |
| 43 | </span> | |
| 44 | ); | |
| 45 | } | |
| 46 | ||
| 47 | /** The square behind an icon in the dock: raised for the current one. */ | |
| 48 | function IconSquare({ current, children }: { current: boolean; children: ReactNode }) { | |
| 49 | return ( | |
| 50 | <span | |
| 51 | className={cn( | |
| 52 | "relative flex size-9 items-center justify-center rounded-[11px] transition-colors", | |
| 53 | current | |
| 54 | ? "bg-raised text-fg shadow-[inset_0_0_0_1px_var(--color-line-strong)]" | |
| 55 | : "text-muted group-hover:bg-raised group-hover:text-fg group-data-[state=open]:bg-raised group-data-[state=open]:text-fg", | |
| 56 | )} | |
| 57 | > | |
| 58 | {children} | |
| 59 | </span> | |
| 60 | ); | |
| 61 | } | |
| 62 | ||
| 63 | /** The bar on the dock's left edge beside the current item. */ | |
| 64 | function CurrentBar() { | |
| 65 | return <span aria-hidden="true" className="absolute top-[9px] -left-1.5 h-5 w-[3px] rounded-r-[3px] bg-fg" />; | |
| 66 | } | |
| 67 | ||
| 68 | // Each item fills the dock's width and centres its icon and name on it. | |
| 69 | const ITEM = "group relative flex w-full flex-col items-center justify-center gap-0.5 rounded-lg py-0.5 text-center outline-none focus-visible:ring-2 focus-visible:ring-accent"; | |
| 70 | ||
| 71 | function DockLink({ to, label, icon, current, badge, compact = false }: { to: string; label: string; icon: ReactNode; current: boolean; badge?: Item["badge"]; compact?: boolean }) { | |
| 72 | const link = ( | |
| 73 | <Link | |
| 74 | to={to} | |
| 75 | prefetch="intent" | |
| 76 | aria-current={current ? "page" : undefined} | |
| 77 | aria-label={badge && badge.count > 0 ? `${label}, ${badge.count} unread` : label} | |
| 78 | className={ITEM} | |
| 79 | > | |
| 80 | {current && <CurrentBar />} | |
| 81 | <IconSquare current={current}> | |
| 82 | {icon} | |
| 83 | {badge && <CountBadge count={badge.count} loud={badge.loud} />} | |
| 84 | </IconSquare> | |
| 85 | {!compact && <DockLabel current={current}>{label}</DockLabel>} | |
| 86 | </Link> | |
| 87 | ); | |
| 88 | // An icon alone says its name on hover. | |
| 89 | return compact ? ( | |
| 90 | <Hint label={label} side="right"> | |
| 91 | {link} | |
| 92 | </Hint> | |
| 93 | ) : ( | |
| 94 | link | |
| 95 | ); | |
| 96 | } | |
| 97 | ||
| 98 | /** A name under an icon: centred on the dock, whatever its length. */ | |
| 99 | function DockLabel({ current, children }: { current: boolean; children: ReactNode }) { | |
| 100 | return ( | |
| 101 | <span className={cn("block w-full truncate text-[0.625rem] leading-tight font-medium transition-colors", current ? "text-fg" : "text-faint group-hover:text-muted")}> | |
| 102 | {children} | |
| 103 | </span> | |
| 104 | ); | |
| 105 | } | |
| 106 | ||
| 107 | function displayName(membership: Membership): string { | |
| 108 | return membership.name?.trim() || membership.slug; | |
| 109 | } | |
| 110 | ||
| 111 | /** | |
| 112 | * The workspace you are in and the way to another: its avatar, its name | |
| 113 | * and a chevron, opening your workspaces, a new one, and your own account. | |
| 114 | * `row` heads the sidebar; `compact` starts the page header's trail when | |
| 115 | * no sidebar is open. | |
| 116 | */ | |
| 117 | export function WorkspaceSwitcher({ user, workspace, compact = false }: { user: User; workspace: Membership; compact?: boolean }) { | |
| 118 | return ( | |
| 119 | <DropdownMenu> | |
| 120 | <DropdownMenuTrigger | |
| 121 | aria-label={`${displayName(workspace)}: switch workspace`} | |
| 122 | className={cn( | |
| 123 | "flex min-w-0 items-center rounded-lg outline-none transition-colors hover:bg-raised focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:bg-raised", | |
| 124 | compact ? "h-8 shrink-0 gap-1.5 px-1 max-sm:max-w-40" : "h-9 grow gap-2 px-1.5", | |
| 125 | )} | |
| 126 | > | |
| 127 | <Avatar name={workspace.slug} image={workspace.avatar} size={compact ? 22 : 26} square /> | |
| 128 | <span className={cn("min-w-0 truncate font-semibold", compact ? "text-sm" : "text-[0.875rem]")}>{displayName(workspace)}</span> | |
| 129 | <ChevronDown size={14} className="shrink-0 text-faint" /> | |
| 130 | </DropdownMenuTrigger> | |
| 131 | <DropdownMenuContent align="start" className="w-72 p-1.5"> | |
| 132 | <DropdownMenuItem asChild className="gap-3 px-2 py-2"> | |
| 133 | <Link to={`/${workspace.slug}/-/workspace`}> | |
| 134 | <Avatar name={workspace.slug} image={workspace.avatar} size={36} square /> | |
| 135 | <span className="flex min-w-0 grow flex-col leading-tight"> | |
| 136 | <span className="truncate text-sm font-medium text-fg">{displayName(workspace)}</span> | |
| 137 | <span className="truncate font-mono text-xs text-muted">g1t.sh/{workspace.slug}</span> | |
| 138 | <span className="mt-0.5 text-xs text-faint capitalize">{workspace.role}</span> | |
| 139 | </span> | |
| 140 | </Link> | |
| 141 | </DropdownMenuItem> | |
| 142 | <DropdownMenuSeparator className="my-1.5" /> | |
| 143 | <DropdownMenuLabel>Switch workspace</DropdownMenuLabel> | |
| 144 | {(user.workspaces ?? []).map((membership) => ( | |
| 145 | <DropdownMenuItem key={membership.slug} asChild> | |
| 146 | <Link to={todayPath(membership.slug)}> | |
| 147 | <Avatar name={membership.slug} image={membership.avatar} size={24} square /> | |
| 148 | <span className="flex min-w-0 grow flex-col leading-tight"> | |
| 149 | <span className="truncate">{displayName(membership)}</span> | |
| 150 | <span className="truncate font-mono text-[0.6875rem] text-faint">{membership.slug}</span> | |
| 151 | </span> | |
| 152 | {membership.slug === workspace.slug ? <Check className="shrink-0 text-accent" /> : <span className="size-4 shrink-0" aria-hidden="true" />} | |
| 153 | </Link> | |
| 154 | </DropdownMenuItem> | |
| 155 | ))} | |
| 156 | <DropdownMenuItem asChild> | |
| 157 | <Link to="/workspaces/new"> | |
| 158 | <Plus /> | |
| 159 | New workspace | |
| 160 | </Link> | |
| 161 | </DropdownMenuItem> | |
| 162 | <DropdownMenuSeparator className="my-1.5" /> | |
| 163 | <DropdownMenuLabel>Personal account</DropdownMenuLabel> | |
| 164 | <DropdownMenuItem asChild> | |
| 165 | <Link to={`/u/${user.username}`}> | |
| 166 | <CircleUserRound /> | |
| 167 | <span className="grow">Your profile</span> | |
| 168 | <span className="font-mono text-xs text-faint">@{shownUsername(user)}</span> | |
| 169 | </Link> | |
| 170 | </DropdownMenuItem> | |
| 171 | <DropdownMenuItem asChild> | |
| 172 | <Link to="/settings"> | |
| 173 | <Settings /> | |
| 174 | Your settings | |
| 175 | </Link> | |
| 176 | </DropdownMenuItem> | |
| 177 | </DropdownMenuContent> | |
| 178 | </DropdownMenu> | |
| 179 | ); | |
| 180 | } | |
| 181 | ||
| 182 | /** Keyboard shortcuts, as the account menu lists them. */ | |
| 183 | function shortcuts(palette: string, sidebar: string): [string, string][] { | |
| 184 | return [ | |
| 185 | [palette, "Search, jump anywhere, or run a command"], | |
| 186 | [sidebar, "Show or hide the sidebar"], | |
| 187 | ["Esc", "Close a menu, dialog or panel"], | |
| 188 | ["Enter", "Send a message in Chat"], | |
| 189 | ["Shift Enter", "A new line in a message"], | |
| 190 | ["@", "Mention a person or an agent"], | |
| 191 | ["↑ ↓ then Tab", "Choose a suggestion"], | |
| 192 | ["Alt ↑ ↓", "Move a pinned project in Code's sidebar"], | |
| 193 | ]; | |
| 194 | } | |
| 195 | ||
| 196 | /** The sidebar's shortcut, as this computer writes it. */ | |
| 197 | export function sidebarKeyLabel(platform: string | null): string { | |
| 198 | return paletteKeyLabel(platform).replace(/K$/, "B"); | |
| 199 | } | |
| 200 | ||
| 201 | /** The keyboard's shortcuts, opened from the account menu. */ | |
| 202 | export function ShortcutsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { | |
| 203 | const platform = typeof navigator === "undefined" ? null : navigator.platform; | |
| 204 | return ( | |
| 205 | <Dialog open={open} onOpenChange={onOpenChange}> | |
| 206 | <DialogContent className="max-w-md"> | |
| 207 | <DialogHeader> | |
| 208 | <DialogTitle>Keyboard shortcuts</DialogTitle> | |
| 209 | <DialogDescription>Everywhere in g1t, and in Chat.</DialogDescription> | |
| 210 | </DialogHeader> | |
| 211 | <dl className="divide-y divide-line rounded-lg border border-line"> | |
| 212 | {shortcuts(paletteKeyLabel(platform), sidebarKeyLabel(platform)).map(([key, what]) => ( | |
| 213 | <div key={key} className="flex items-center justify-between gap-4 px-3.5 py-2.5 text-sm"> | |
| 214 | <dt className="text-fg-soft">{what}</dt> | |
| 215 | <dd> | |
| 216 | <kbd className="rounded-md border border-line-strong bg-raised px-1.5 py-0.5 font-sans text-xs whitespace-nowrap text-fg">{key}</kbd> | |
| 217 | </dd> | |
| 218 | </div> | |
| 219 | ))} | |
| 220 | </dl> | |
| 221 | </DialogContent> | |
| 222 | </Dialog> | |
| 223 | ); | |
| 224 | } | |
| 225 | ||
| 226 | /** The Apps button at the foot of the dock's apps, and the launcher it opens. */ | |
| 227 | function AppsButton({ slug, code, pins, onToggle, current }: { slug: string; code: boolean; pins: PinnableApp[]; onToggle: (app: PinnableApp) => void; current: boolean }) { | |
| 228 | const [open, setOpen] = useState(false); | |
| 229 | return ( | |
| 230 | <Popover open={open} onOpenChange={setOpen}> | |
| 231 | <PopoverTrigger aria-label="Apps" className={ITEM}> | |
| 232 | {current && <CurrentBar />} | |
| 233 | <IconSquare current={current || open}> | |
| 234 | <LayoutGrid size={19} /> | |
| 235 | </IconSquare> | |
| 236 | <DockLabel current={current || open}>Apps</DockLabel> | |
| 237 | </PopoverTrigger> | |
| 238 | <PopoverContent side="right" align="end" sideOffset={14} aria-label="Apps" className="w-[23rem] bg-dock p-2.5"> | |
| 239 | <AppsLauncher slug={slug} code={code} pins={pins} onToggle={onToggle} onClose={() => setOpen(false)} /> | |
| 240 | </PopoverContent> | |
| 241 | </Popover> | |
| 242 | ); | |
| 243 | } | |
| 244 | ||
| 245 | /** | |
| 246 | * The dock down the left (docs/WORKSPACE.md, "Shell"): g1t's mark, which | |
| 247 | * leads to Today; the built-in apps (Today, Chat, Notifications, Agents, | |
| 248 | * Code and Artifacts) with their names; the apps you pinned, as icons; | |
| 249 | * the Apps launcher; and at its foot People, Workspace and your account. | |
| 250 | * What is lit follows the address. A member without Code access has no | |
| 251 | * Code. From 768px; a phone has the bottom bar (components/mobile.tsx). | |
| 252 | */ | |
| 253 | export function Dock({ | |
| 254 | workspace, | |
| 255 | pins: saved, | |
| 256 | unread, | |
| 257 | account, | |
| 258 | }: { | |
| 259 | workspace: Membership; | |
| 260 | /** The apps pinned to this person's dock in this workspace, as saved. */ | |
| 261 | pins: PinnableApp[]; | |
| 262 | unread: { notifications: number; chat: number; mentions: number }; | |
| 263 | account: ReactNode; | |
| 264 | }) { | |
| 265 | const { pathname } = useLocation(); | |
| 266 | const going = useNavigation().location?.pathname; | |
| 267 | const path = going ?? pathname; | |
| 268 | const here = modeOf(path, workspace.slug); | |
| 269 | const slug = workspace.slug; | |
| 270 | const code = hasCodeAccess(workspace); | |
| 271 | const { pins, toggle } = useAppPins(slug, saved); | |
| 272 | const builtins: Item[] = [ | |
| 273 | { key: "today", label: "Today" }, | |
| 274 | { | |
| 275 | key: "chat", | |
| 276 | label: "Chat", | |
| 277 | badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 }, | |
| 278 | }, | |
| 279 | { key: "notifications", label: "Notifications", badge: { count: unread.notifications, loud: true } }, | |
| 280 | { key: "agents", label: "Agents" }, | |
| 281 | ...(code ? [{ key: "code" as const, label: "Code" }] : []), | |
| 282 | { key: "artifacts", label: "Artifacts" }, | |
| 283 | ]; | |
| 284 | const shown = pins.filter((key) => code || !appOf(key).code); | |
| 285 | // A pinned app is current on its own page and the pages under it. | |
| 286 | const at = (to: string) => path === to || path.startsWith(`${to}/`); | |
| 287 | return ( | |
| 288 | <nav | |
| 289 | aria-label="Apps" | |
| 290 | className="flex h-full flex-col overflow-hidden rounded-2xl border border-line bg-dock shadow-[0_1px_0_rgba(255,255,255,0.03)_inset,0_8px_24px_rgba(0,0,0,0.18)]" | |
| 291 | > | |
| 292 | <div className="flex h-14 shrink-0 items-center justify-center"> | |
| 293 | <Hint label="Today" side="right"> | |
| 294 | <Link to={todayPath(slug)} aria-label="g1t: Today" className="flex size-9 items-center justify-center rounded-[11px] transition-colors hover:bg-raised"> | |
| 295 | <Mark tight className="h-[1.125rem] w-auto" /> | |
| 296 | </Link> | |
| 297 | </Hint> | |
| 298 | </div> | |
| 299 | <div className="flex w-full shrink-0 flex-col items-center gap-1 px-1.5 pb-1.5"> | |
| 300 | {builtins.map((item) => ( | |
| 301 | <DockLink | |
| 302 | key={item.key} | |
| 303 | to={modeHome(item.key, slug)} | |
| 304 | label={item.label} | |
| 305 | icon={appIcon(item.key as AppKey, 19)} | |
| 306 | current={here === item.key && !shown.some((key) => at(appOf(key).path(slug)))} | |
| 307 | badge={item.badge} | |
| 308 | /> | |
| 309 | ))} | |
| 310 | </div> | |
| 311 | {/* Your pinned apps: as many as you like, scrolling under a fade at either end. */} | |
| 312 | <div className="flex min-h-0 w-full grow flex-col items-center gap-1 overflow-y-auto border-t border-line px-1.5 py-2 [mask-image:linear-gradient(to_bottom,transparent,#000_10px,#000_calc(100%-14px),transparent)] [scrollbar-width:none]"> | |
| 313 | {shown.map((key) => { | |
| 314 | const app = appOf(key); | |
| 315 | return <DockLink key={key} to={app.path(slug)} label={app.name} icon={appIcon(key, 18)} current={at(app.path(slug))} compact />; | |
| 316 | })} | |
| 317 | </div> | |
| 318 | <div className="flex w-full shrink-0 flex-col items-center px-1.5 pb-1.5"> | |
| 319 | <AppsButton slug={slug} code={code} pins={pins} onToggle={toggle} current={here === "apps"} /> | |
| 320 | </div> | |
| 321 | <div className="flex w-full shrink-0 flex-col items-center gap-1 border-t border-line px-1.5 pt-2 pb-2.5"> | |
| 322 | <DockLink to={modeHome("people", slug)} label="People" icon={appIcon("people", 19)} current={here === "people"} /> | |
| 323 | <DockLink to={modeHome("workspace", slug)} label="Workspace" icon={appIcon("workspace", 19)} current={here === "workspace"} /> | |
| 324 | <div className="mt-1.5">{account}</div> | |
| 325 | </div> | |
| 326 | </nav> | |
| 327 | ); | |
| 328 | } |