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.
| The rail itself ships with the navigation it was committed without: the file that draws it was renamed from the dock and left out of the last change. | 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, pinnedApp, useAppPins } from "./apps"; | |
| 8 | import { Mark } from "./logo"; | |
| 9 | ||
| 10 | import { Avatar } from "./ui/avatar"; | |
| 11 | import { Card } from "./ui/card"; | |
| 12 | import { Hint } from "./ui/hint"; | |
| 13 | import { Kbd } from "./ui/kbd"; | |
| 14 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"; | |
| 15 | import { | |
| 16 | DropdownMenu, | |
| 17 | DropdownMenuContent, | |
| 18 | DropdownMenuItem, | |
| 19 | DropdownMenuLabel, | |
| 20 | DropdownMenuSeparator, | |
| 21 | DropdownMenuTrigger, | |
| 22 | } from "./ui/dropdown-menu"; | |
| 23 | import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; | |
| 24 | import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarSeparator } from "./ui/sidebar"; | |
| 25 | import { type BuiltinApp, type PinnableApp } from "../lib/apps"; | |
| 26 | import { cn } from "../lib/cn"; | |
| 27 | import { paletteKeyLabel } from "../lib/shortcut"; | |
| 28 | import { type ModeKey, homePagePath, modeHome, modeOf } from "../lib/workspace-nav"; | |
| 29 | ||
| 30 | type Item = { key: ModeKey; label: string; badge?: { count: number; loud: boolean } | null }; | |
| 31 | ||
| 32 | /** A count on an icon: loud (the accent) for what waits on you, quiet for the rest. */ | |
| 33 | export function CountBadge({ count, loud, className }: { count: number; loud: boolean; className?: string }) { | |
| 34 | if (count <= 0) return null; | |
| 35 | return ( | |
| 36 | <span | |
| 37 | className={cn( | |
| 38 | "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-rail", | |
| 39 | loud ? "bg-accent text-bg" : "bg-line-strong text-fg", | |
| 40 | className, | |
| 41 | )} | |
| 42 | > | |
| 43 | {count > 99 ? "99+" : count} | |
| 44 | </span> | |
| 45 | ); | |
| 46 | } | |
| 47 | ||
| 48 | /** One icon of the rail: a link to an app, its name as the hint beside it, and what is unread on it. */ | |
| 49 | function RailLink({ to, label, icon, current, badge }: { to: string; label: string; icon: ReactNode; current: boolean; badge?: Item["badge"] }) { | |
| 50 | return ( | |
| 51 | <SidebarMenuItem> | |
| 52 | <SidebarMenuButton asChild size="icon" isActive={current} tooltip={label}> | |
| 53 | <Link to={to} prefetch="intent" aria-current={current ? "page" : undefined} aria-label={badge && badge.count > 0 ? `${label}, ${badge.count} unread` : label}> | |
| 54 | {icon} | |
| 55 | {badge && <CountBadge count={badge.count} loud={badge.loud} />} | |
| 56 | </Link> | |
| 57 | </SidebarMenuButton> | |
| 58 | </SidebarMenuItem> | |
| 59 | ); | |
| 60 | } | |
| 61 | ||
| 62 | function displayName(membership: Membership): string { | |
| 63 | return membership.name?.trim() || membership.slug; | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * The workspace you are in and the way to another: its avatar, its name | |
| 68 | * and a chevron, opening your workspaces, a new one, and your own account. | |
| 69 | * `row` heads the sidebar; `compact` starts the page header's trail when | |
| 70 | * no sidebar is open. | |
| 71 | */ | |
| 72 | export function WorkspaceSwitcher({ user, workspace, compact = false }: { user: User; workspace: Membership; compact?: boolean }) { | |
| 73 | return ( | |
| 74 | <DropdownMenu> | |
| 75 | <DropdownMenuTrigger | |
| 76 | aria-label={`${displayName(workspace)}: switch workspace`} | |
| 77 | className={cn( | |
| 78 | "flex min-w-0 items-center rounded-lg outline-none transition-colors hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:bg-sidebar-accent", | |
| 79 | compact ? "h-8 shrink-0 gap-1.5 px-1 max-md:h-11 max-sm:gap-1 hover:bg-raised data-[state=open]:bg-raised" : "h-9 grow gap-2 px-1.5", | |
| 80 | )} | |
| 81 | > | |
| 82 | <Avatar name={workspace.slug} image={workspace.avatar} size={compact ? 22 : 26} square /> | |
| 83 | <span className={cn("min-w-0 truncate font-semibold", compact ? "text-sm max-sm:hidden" : "text-[0.875rem]")}>{displayName(workspace)}</span> | |
| 84 | <ChevronDown size={14} className="shrink-0 text-faint" /> | |
| 85 | </DropdownMenuTrigger> | |
| 86 | <DropdownMenuContent align="start" className="w-72 p-1.5"> | |
| 87 | <DropdownMenuItem asChild className="gap-3 px-2 py-2"> | |
| 88 | <Link to={`/${workspace.slug}/-/workspace`}> | |
| 89 | <Avatar name={workspace.slug} image={workspace.avatar} size={36} square /> | |
| 90 | <span className="flex min-w-0 grow flex-col leading-tight"> | |
| 91 | <span className="truncate text-sm font-medium text-fg">{displayName(workspace)}</span> | |
| 92 | <span className="truncate font-mono text-xs text-muted">g1t.sh/{workspace.slug}</span> | |
| 93 | <span className="mt-0.5 text-xs text-faint capitalize">{workspace.role}</span> | |
| 94 | </span> | |
| 95 | </Link> | |
| 96 | </DropdownMenuItem> | |
| 97 | <DropdownMenuSeparator className="my-1.5" /> | |
| 98 | <DropdownMenuLabel>Switch workspace</DropdownMenuLabel> | |
| 99 | {(user.workspaces ?? []).map((membership) => ( | |
| 100 | <DropdownMenuItem key={membership.slug} asChild> | |
| 101 | <Link to={homePagePath(membership.slug)}> | |
| 102 | <Avatar name={membership.slug} image={membership.avatar} size={24} square /> | |
| 103 | <span className="flex min-w-0 grow flex-col leading-tight"> | |
| 104 | <span className="truncate">{displayName(membership)}</span> | |
| 105 | <span className="truncate font-mono text-[0.6875rem] text-faint">{membership.slug}</span> | |
| 106 | </span> | |
| 107 | {membership.slug === workspace.slug ? <Check className="shrink-0 text-accent" /> : <span className="size-4 shrink-0" aria-hidden="true" />} | |
| 108 | </Link> | |
| 109 | </DropdownMenuItem> | |
| 110 | ))} | |
| 111 | <DropdownMenuItem asChild> | |
| 112 | <Link to="/workspaces/new"> | |
| 113 | <Plus /> | |
| 114 | New workspace | |
| 115 | </Link> | |
| 116 | </DropdownMenuItem> | |
| 117 | <DropdownMenuSeparator className="my-1.5" /> | |
| 118 | <DropdownMenuLabel>Personal account</DropdownMenuLabel> | |
| 119 | <DropdownMenuItem asChild> | |
| 120 | <Link to={`/u/${user.username}`}> | |
| 121 | <CircleUserRound /> | |
| 122 | <span className="grow">Your profile</span> | |
| 123 | <span className="font-mono text-xs text-faint">@{shownUsername(user)}</span> | |
| 124 | </Link> | |
| 125 | </DropdownMenuItem> | |
| 126 | <DropdownMenuItem asChild> | |
| 127 | <Link to="/settings"> | |
| 128 | <Settings /> | |
| 129 | Your settings | |
| 130 | </Link> | |
| 131 | </DropdownMenuItem> | |
| 132 | </DropdownMenuContent> | |
| 133 | </DropdownMenu> | |
| 134 | ); | |
| 135 | } | |
| 136 | ||
| 137 | /** Keyboard shortcuts, as the account menu lists them. */ | |
| 138 | function shortcuts(palette: string, sidebar: string): [string, string][] { | |
| 139 | return [ | |
| 140 | [palette, "Search, jump anywhere, or run a command"], | |
| 141 | [sidebar, "Show or hide the sidebar"], | |
| 142 | ["Esc", "Close a menu, dialog or panel"], | |
| 143 | ["Enter", "Send a message in Chat"], | |
| 144 | ["Shift Enter", "A new line in a message"], | |
| 145 | ["@", "Mention a person or an agent"], | |
| 146 | ["↑ ↓ then Tab", "Choose a suggestion"], | |
| 147 | ["Alt ↑ ↓", "Move a pinned project in Code's sidebar"], | |
| 148 | ]; | |
| 149 | } | |
| 150 | ||
| 151 | /** The sidebar's shortcut, as this computer writes it. */ | |
| 152 | export function sidebarKeyLabel(platform: string | null): string { | |
| 153 | return paletteKeyLabel(platform).replace(/K$/, "B"); | |
| 154 | } | |
| 155 | ||
| 156 | /** The keyboard's shortcuts, opened from the account menu. */ | |
| 157 | export function ShortcutsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { | |
| 158 | const platform = typeof navigator === "undefined" ? null : navigator.platform; | |
| 159 | return ( | |
| 160 | <Dialog open={open} onOpenChange={onOpenChange}> | |
| 161 | <DialogContent className="max-w-md"> | |
| 162 | <DialogHeader> | |
| 163 | <DialogTitle>Keyboard shortcuts</DialogTitle> | |
| 164 | <DialogDescription>Everywhere in g1t, and in Chat.</DialogDescription> | |
| 165 | </DialogHeader> | |
| 166 | <Card asChild tone="plain" radius="lg" divided> | |
| 167 | <dl> | |
| 168 | {shortcuts(paletteKeyLabel(platform), sidebarKeyLabel(platform)).map(([key, what]) => ( | |
| 169 | <div key={key} className="flex items-center justify-between gap-4 px-3.5 py-2.5 text-sm"> | |
| 170 | <dt className="text-fg-soft">{what}</dt> | |
| 171 | <dd> | |
| 172 | <Kbd className="px-1.5 py-0.5 font-sans text-xs whitespace-nowrap text-fg ring-line-strong">{key}</Kbd> | |
| 173 | </dd> | |
| 174 | </div> | |
| 175 | ))} | |
| 176 | </dl> | |
| 177 | </Card> | |
| 178 | </DialogContent> | |
| 179 | </Dialog> | |
| 180 | ); | |
| 181 | } | |
| 182 | ||
| 183 | /** The Apps icon at the foot of the rail's apps, and the launcher it opens beside it. */ | |
| 184 | function AppsButton({ slug, pins, onToggle, current }: { slug: string; pins: PinnableApp[]; onToggle: (app: PinnableApp) => void; current: boolean }) { | |
| 185 | const [open, setOpen] = useState(false); | |
| 186 | return ( | |
| 187 | <Popover open={open} onOpenChange={setOpen}> | |
| 188 | <SidebarMenuButton asChild size="icon" isActive={current} tooltip="Apps"> | |
| 189 | <PopoverTrigger aria-label="Apps"> | |
| 190 | <LayoutGrid /> | |
| 191 | </PopoverTrigger> | |
| 192 | </SidebarMenuButton> | |
| 193 | <PopoverContent side="right" align="end" sideOffset={12} aria-label="Apps" className="w-[23rem] p-2.5"> | |
| 194 | <AppsLauncher slug={slug} pins={pins} onToggle={onToggle} onClose={() => setOpen(false)} /> | |
| 195 | </PopoverContent> | |
| 196 | </Popover> | |
| 197 | ); | |
| 198 | } | |
| 199 | ||
| 200 | /** | |
| 201 | * The rail down the window's left edge: a narrow column of icons, each | |
| 202 | * named by the hint beside it. g1t's mark, which leads to Home; the | |
| Notifications is the bell in the top bar alone: the rail no longer carries it, and the notifications guide points at the bell; a phone keeps it in the bottom bar, which has no bell. Home names who made a deployment, by username or by an agent's handle, instead of showing the account's id, and says someone when identity doesn't know the id. The Apps launcher's skeleton tiles are drawn in the line colour so they show on its raised surface. | 203 | * built-in apps (Home, Chat, Agents, Code and Artifacts) with what is |
| 204 | * unread on them (Notifications is the bell in the top bar, not a rail | |
| 205 | * app); the apps you pinned; the Apps launcher; | |
| The rail itself ships with the navigation it was committed without: the file that draws it was renamed from the dock and left out of the last change. | 206 | * and at its foot People, Workspace and your account. The one you are in |
| 207 | * sits on a filled square, and which that is follows the address. A | |
| 208 | * member without Code access has no Code. From 768px; a phone has the | |
| 209 | * bottom bar (components/mobile.tsx). Drawn from the sidebar kit | |
| 210 | * (components/ui/sidebar.tsx), as the mode sidebar beside it is. | |
| 211 | */ | |
| 212 | export function Rail({ | |
| 213 | workspace, | |
| 214 | pins: saved, | |
| 215 | unread, | |
| 216 | account, | |
| 217 | }: { | |
| 218 | workspace: Membership; | |
| 219 | /** The apps pinned to this person's rail in this workspace, as saved. */ | |
| 220 | pins: PinnableApp[]; | |
| 221 | unread: { notifications: number; chat: number; mentions: number }; | |
| 222 | account: ReactNode; | |
| 223 | }) { | |
| 224 | const { pathname } = useLocation(); | |
| 225 | const going = useNavigation().location?.pathname; | |
| 226 | const path = going ?? pathname; | |
| 227 | const here = modeOf(path, workspace.slug); | |
| 228 | const slug = workspace.slug; | |
| 229 | const code = hasCodeAccess(workspace); | |
| 230 | const { pins, toggle } = useAppPins(slug, saved); | |
| 231 | const builtins: Item[] = [ | |
| 232 | { key: "home", label: "Home" }, | |
| 233 | { | |
| 234 | key: "chat", | |
| 235 | label: "Chat", | |
| 236 | badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 }, | |
| 237 | }, | |
| 238 | { key: "agents", label: "Agents" }, | |
| 239 | ...(code ? [{ key: "code" as const, label: "Code" }] : []), | |
| 240 | { key: "artifacts", label: "Artifacts" }, | |
| 241 | ]; | |
| 242 | const shown = pins.map((key) => pinnedApp(key, slug)).filter((app) => app != null); | |
| 243 | // A pinned app is current on its own page and the pages under it. | |
| 244 | const at = (to: string) => path === to || path.startsWith(`${to}/`); | |
| 245 | return ( | |
| 246 | <Sidebar variant="rail" collapsible="none" role="navigation" aria-label="Apps"> | |
| 247 | <SidebarHeader className="h-14 items-center justify-center p-0"> | |
| 248 | <SidebarMenu className="w-auto"> | |
| 249 | <SidebarMenuItem> | |
| 250 | <SidebarMenuButton asChild size="icon" tooltip="Home"> | |
| 251 | <Link to={homePagePath(slug)} aria-label="g1t: Home"> | |
| 252 | <span className="flex"> | |
| 253 | <Mark tight className="h-[1.125rem] w-auto" /> | |
| 254 | </span> | |
| 255 | </Link> | |
| 256 | </SidebarMenuButton> | |
| 257 | </SidebarMenuItem> | |
| 258 | </SidebarMenu> | |
| 259 | </SidebarHeader> | |
| 260 | <SidebarContent className="items-center gap-0"> | |
| 261 | <SidebarMenu className="w-auto shrink-0 gap-2"> | |
| 262 | {builtins.map((item) => ( | |
| 263 | <RailLink | |
| 264 | key={item.key} | |
| 265 | to={modeHome(item.key, slug)} | |
| 266 | label={item.label} | |
| 267 | icon={appIcon(item.key as BuiltinApp, 20)} | |
| 268 | current={here === item.key && !shown.some((app) => at(app.to))} | |
| 269 | badge={item.badge} | |
| 270 | /> | |
| 271 | ))} | |
| 272 | </SidebarMenu> | |
| 273 | {/* Your pinned apps: as many as you like, scrolling under a fade at either end. */} | |
| 274 | {shown.length > 0 && ( | |
| 275 | <SidebarMenu className="mt-2 w-auto min-h-0 grow gap-2 overflow-y-auto py-2 [mask-image:linear-gradient(to_bottom,transparent,#000_10px,#000_calc(100%-14px),transparent)] [scrollbar-width:none]"> | |
| 276 | {shown.map((app) => ( | |
| 277 | <RailLink key={app.key} to={app.to} label={app.name} icon={app.icon(20)} current={at(app.to)} /> | |
| 278 | ))} | |
| 279 | </SidebarMenu> | |
| 280 | )} | |
| 281 | </SidebarContent> | |
| 282 | <SidebarFooter className="items-center gap-0 p-0 pb-3"> | |
| 283 | <SidebarMenu className="w-auto gap-2"> | |
| 284 | <SidebarMenuItem> | |
| 285 | <AppsButton slug={slug} pins={pins} onToggle={toggle} current={here === "apps"} /> | |
| 286 | </SidebarMenuItem> | |
| 287 | </SidebarMenu> | |
| 288 | <SidebarSeparator className="my-2.5 w-7" /> | |
| 289 | <SidebarMenu className="w-auto gap-2"> | |
| 290 | <RailLink to={modeHome("people", slug)} label="People" icon={appIcon("people", 20)} current={here === "people"} /> | |
| 291 | <RailLink to={modeHome("workspace", slug)} label="Workspace" icon={appIcon("workspace", 20)} current={here === "workspace"} /> | |
| 292 | <SidebarMenuItem className="mt-1 flex justify-center">{account}</SidebarMenuItem> | |
| 293 | </SidebarMenu> | |
| 294 | </SidebarFooter> | |
| 295 | </Sidebar> | |
| 296 | ); | |
| 297 | } |