Skip to content
297 linesCodeBlameRaw

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.1import { Check, ChevronDown, CircleUserRound, LayoutGrid, Plus, Settings } from "lucide-react";
2import { type ReactNode, useState } from "react";
3import { Link, useLocation, useNavigation } from "react-router";
4
5import { type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts";
6
7import { AppsLauncher, appIcon, pinnedApp, useAppPins } from "./apps";
8import { Mark } from "./logo";
9
10import { Avatar } from "./ui/avatar";
11import { Card } from "./ui/card";
12import { Hint } from "./ui/hint";
13import { Kbd } from "./ui/kbd";
14import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog";
15import {
16 DropdownMenu,
17 DropdownMenuContent,
18 DropdownMenuItem,
19 DropdownMenuLabel,
20 DropdownMenuSeparator,
21 DropdownMenuTrigger,
22} from "./ui/dropdown-menu";
23import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
24import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarSeparator } from "./ui/sidebar";
25import { type BuiltinApp, type PinnableApp } from "../lib/apps";
26import { cn } from "../lib/cn";
27import { paletteKeyLabel } from "../lib/shortcut";
28import { type ModeKey, homePagePath, modeHome, modeOf } from "../lib/workspace-nav";
29
30type 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. */
33export 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. */
49function 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
62function 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 */
72export 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. */
138function 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. */
152export 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. */
157export 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. */
184function 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 */
212export 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}