Skip to content
297 linesCodeBlameRaw
1import { Activity, BookOpen, Building2, Check, CircleHelp, CircleUserRound, Code2, House, Inbox, Keyboard, LifeBuoy, MessagesSquare, Plus, Settings, Shapes, Sparkles } 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 { Avatar } from "./ui";
8import { Hint } from "./ui/hint";
9import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog";
10import {
11 DropdownMenu,
12 DropdownMenuContent,
13 DropdownMenuItem,
14 DropdownMenuLabel,
15 DropdownMenuSeparator,
16 DropdownMenuTrigger,
17} from "./ui/dropdown-menu";
18import { paletteKeyLabel } from "../lib/shortcut";
19import { STATUS_URL } from "../lib/status";
20import { type ModeKey, modeHome, modeOf } from "../lib/workspace-nav";
21
22/** The rail's width; the shell lays the mode's sidebar and the page out beside it. */
23export const RAIL_WIDTH = "5rem";
24
25type Mode = { key: ModeKey; label: string; icon: ReactNode; badge?: { count: number; loud: boolean } | null };
26
27function Badge({ count, loud }: { count: number; loud: boolean }) {
28 if (count <= 0) return null;
29 return (
30 <span
31 className={`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-[var(--rail-bg)] ${
32 loud ? "bg-accent text-bg" : "bg-[#3d3d45] text-fg"
33 }`}
34 >
35 {count > 99 ? "99+" : count}
36 </span>
37 );
38}
39
40/** The square behind a rail icon: filled for the current mode. */
41function IconSquare({ current, children }: { current: boolean; children: ReactNode }) {
42 return (
43 <span
44 className={`relative flex size-9 items-center justify-center rounded-[10px] transition-colors ${
45 current
46 ? "bg-[#2c2c33] text-fg shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)]"
47 : "text-muted group-hover:bg-raised group-hover:text-fg group-data-[state=open]:bg-raised group-data-[state=open]:text-fg"
48 }`}
49 >
50 {children}
51 </span>
52 );
53}
54
55/** A mode's name under its icon: centred on the rail, whatever its length. */
56function RailLabel({ current, children }: { current: boolean; children: ReactNode }) {
57 return (
58 <span
59 className={`block w-full truncate text-center text-[0.6875rem] leading-none font-medium transition-colors ${current ? "text-fg" : "text-faint group-hover:text-muted"}`}
60 >
61 {children}
62 </span>
63 );
64}
65
66// Each item fills the rail's width and centres its icon and name on it.
67const RAIL_ITEM =
68 "group flex w-full flex-col items-center justify-center gap-1 rounded-lg py-1 text-center outline-none focus-visible:ring-2 focus-visible:ring-accent";
69
70function RailButton({ mode, to, current }: { mode: Mode; to: string; current: boolean }) {
71 return (
72 <Link
73 to={to}
74 prefetch="intent"
75 aria-current={current ? "page" : undefined}
76 aria-label={mode.badge && mode.badge.count > 0 ? `${mode.label}, ${mode.badge.count} unread` : mode.label}
77 className={RAIL_ITEM}
78 >
79 <IconSquare current={current}>
80 {mode.icon}
81 {mode.badge && <Badge count={mode.badge.count} loud={mode.badge.loud} />}
82 </IconSquare>
83 <RailLabel current={current}>{mode.label}</RailLabel>
84 </Link>
85 );
86}
87
88function displayName(membership: Membership): string {
89 return membership.name?.trim() || membership.slug;
90}
91
92/**
93 * The workspace at the top of the rail: its avatar, and the switcher. Your
94 * workspaces, a new one, and your own account, which is no workspace's.
95 */
96function WorkspaceButton({ user, workspace }: { user: User; workspace: Membership }) {
97 return (
98 <DropdownMenu>
99 <Hint label={displayName(workspace)} side="right">
100 <DropdownMenuTrigger
101 aria-label={`${displayName(workspace)}: switch workspace`}
102 className="flex rounded-[10px] outline-none transition-transform hover:scale-[1.04] focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:ring-2 data-[state=open]:ring-line-strong"
103 >
104 <Avatar name={workspace.slug} image={workspace.avatar} size={36} square />
105 </DropdownMenuTrigger>
106 </Hint>
107 <DropdownMenuContent side="right" align="start" className="w-72 p-1.5">
108 <DropdownMenuItem asChild className="gap-3 px-2 py-2">
109 <Link to={`/${workspace.slug}/-/workspace`}>
110 <Avatar name={workspace.slug} image={workspace.avatar} size={36} square />
111 <span className="flex min-w-0 grow flex-col leading-tight">
112 <span className="truncate text-sm font-medium text-fg">{displayName(workspace)}</span>
113 <span className="truncate font-mono text-xs text-muted">g1t.sh/{workspace.slug}</span>
114 <span className="mt-0.5 text-xs text-faint capitalize">{workspace.role}</span>
115 </span>
116 </Link>
117 </DropdownMenuItem>
118 <DropdownMenuSeparator className="my-1.5" />
119 <DropdownMenuLabel>Switch workspace</DropdownMenuLabel>
120 {(user.workspaces ?? []).map((membership) => (
121 <DropdownMenuItem key={membership.slug} asChild>
122 <Link to={`/${membership.slug}/-/home`}>
123 <Avatar name={membership.slug} image={membership.avatar} size={24} square />
124 <span className="flex min-w-0 grow flex-col leading-tight">
125 <span className="truncate">{displayName(membership)}</span>
126 <span className="truncate font-mono text-[0.6875rem] text-faint">{membership.slug}</span>
127 </span>
128 {membership.slug === workspace.slug ? <Check className="shrink-0 text-accent" /> : <span className="size-4 shrink-0" aria-hidden="true" />}
129 </Link>
130 </DropdownMenuItem>
131 ))}
132 <DropdownMenuItem asChild>
133 <Link to="/workspaces/new">
134 <Plus />
135 New workspace
136 </Link>
137 </DropdownMenuItem>
138 <DropdownMenuSeparator className="my-1.5" />
139 <DropdownMenuLabel>Personal account</DropdownMenuLabel>
140 <DropdownMenuItem asChild>
141 <Link to={`/u/${user.username}`}>
142 <CircleUserRound />
143 <span className="grow">Your profile</span>
144 <span className="font-mono text-xs text-faint">@{shownUsername(user)}</span>
145 </Link>
146 </DropdownMenuItem>
147 <DropdownMenuItem asChild>
148 <Link to="/settings">
149 <Settings />
150 Your settings
151 </Link>
152 </DropdownMenuItem>
153 </DropdownMenuContent>
154 </DropdownMenu>
155 );
156}
157
158/** Keyboard shortcuts, as the Help menu lists them. */
159function shortcuts(palette: string): [string, string][] {
160 return [
161 [palette, "Search, jump anywhere, or run a command"],
162 ["Esc", "Close a menu, dialog or panel"],
163 ["Enter", "Send a message in Chat"],
164 ["Shift Enter", "A new line in a message"],
165 ["@", "Mention a person or an agent"],
166 ["↑ ↓ then Tab", "Choose a suggestion"],
167 ["Alt ↑ ↓", "Move a pinned project in Code's sidebar"],
168 ];
169}
170
171/** Help, above the account: support, the documentation, status, and the keyboard's shortcuts. */
172export function HelpMenu() {
173 const [keys, setKeys] = useState(false);
174 const palette = paletteKeyLabel(typeof navigator === "undefined" ? null : navigator.platform);
175 return (
176 <>
177 <DropdownMenu>
178 <DropdownMenuTrigger aria-label="Help" className={RAIL_ITEM}>
179 <IconSquare current={false}>
180 <CircleHelp size={19} />
181 </IconSquare>
182 <RailLabel current={false}>Help</RailLabel>
183 </DropdownMenuTrigger>
184 <DropdownMenuContent side="right" align="end" className="w-60">
185 <DropdownMenuLabel>Help</DropdownMenuLabel>
186 <DropdownMenuItem asChild>
187 <Link to="/support">
188 <LifeBuoy />
189 Support
190 </Link>
191 </DropdownMenuItem>
192 <DropdownMenuItem asChild>
193 <a href="https://docs.g1t.sh/">
194 <BookOpen />
195 Documentation
196 </a>
197 </DropdownMenuItem>
198 <DropdownMenuItem asChild>
199 <a href={STATUS_URL}>
200 <Activity />
201 Status
202 </a>
203 </DropdownMenuItem>
204 <DropdownMenuSeparator />
205 <DropdownMenuItem onSelect={() => setKeys(true)}>
206 <Keyboard />
207 Keyboard shortcuts
208 <kbd className="ml-auto font-sans text-xs text-faint">{palette}</kbd>
209 </DropdownMenuItem>
210 </DropdownMenuContent>
211 </DropdownMenu>
212 <Dialog open={keys} onOpenChange={setKeys}>
213 <DialogContent className="max-w-md">
214 <DialogHeader>
215 <DialogTitle>Keyboard shortcuts</DialogTitle>
216 <DialogDescription>Everywhere in g1t, and in Chat.</DialogDescription>
217 </DialogHeader>
218 <dl className="divide-y divide-line rounded-lg border border-line">
219 {shortcuts(palette).map(([key, what]) => (
220 <div key={key} className="flex items-center justify-between gap-4 px-3.5 py-2.5 text-sm">
221 <dt className="text-fg-soft">{what}</dt>
222 <dd>
223 <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>
224 </dd>
225 </div>
226 ))}
227 </dl>
228 </DialogContent>
229 </Dialog>
230 </>
231 );
232}
233
234/**
235 * The rail down the left (docs/WORKSPACE.md, "Shell"): the workspace and
236 * its switcher; Home, Code, Chat, Docs, Agents and the Inbox; then, at the
237 * foot, the workspace itself, help and your account. Code sits second: for
238 * those who have it, it is the everyday mode. The mode lit follows the
239 * address. A member without Code access has no Code.
240 */
241export function Rail({
242 user,
243 workspace,
244 unread,
245 help,
246 account,
247}: {
248 user: User;
249 workspace: Membership;
250 unread: { inbox: number; chat: number; mentions: number };
251 help: ReactNode;
252 account: ReactNode;
253}) {
254 const { pathname } = useLocation();
255 const going = useNavigation().location?.pathname;
256 const here = modeOf(going ?? pathname, workspace.slug);
257 const code = hasCodeAccess(workspace);
258 const modes: Mode[] = [
259 { key: "home", label: "Home", icon: <House size={19} /> },
260 ...(code ? [{ key: "code" as const, label: "Code", icon: <Code2 size={19} /> }] : []),
261 {
262 key: "chat",
263 label: "Chat",
264 icon: <MessagesSquare size={19} />,
265 badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 },
266 },
267 { key: "artifacts", label: "Artifacts", icon: <Shapes size={19} /> },
268 { key: "agents", label: "Agents", icon: <Sparkles size={19} /> },
269 { key: "inbox", label: "Inbox", icon: <Inbox size={19} />, badge: { count: unread.inbox, loud: true } },
270 ];
271 const workspaceMode: Mode = { key: "workspace", label: "Workspace", icon: <Building2 size={19} /> };
272 return (
273 // The right-hand rule is an inset shadow, not a border, so the rail's
274 // whole width is its content box and every item centres on it exactly.
275 <nav
276 aria-label="Modes"
277 style={{ width: RAIL_WIDTH, ["--rail-bg" as string]: "#0b0b0d" }}
278 className="flex h-full shrink-0 flex-col items-center overflow-y-auto bg-[var(--rail-bg)] pb-3 shadow-[inset_-1px_0_0_var(--color-line)] [scrollbar-width:none]"
279 >
280 {/* The top bar's height, rule and colour: the workspace sits in the top bar's line, tied to it as the
281 sidebar's heading is, rather than on the rail below it. */}
282 <div className="flex h-14 w-full shrink-0 items-center justify-center border-b border-line bg-bg shadow-[inset_-1px_0_0_var(--color-line)]">
283 <WorkspaceButton user={user} workspace={workspace} />
284 </div>
285 <div className="mt-3 flex w-full flex-col items-center gap-2 px-1.5">
286 {modes.map((mode) => (
287 <RailButton key={mode.key} mode={mode} to={modeHome(mode.key, workspace.slug)} current={here === mode.key} />
288 ))}
289 </div>
290 <div className="mt-auto flex w-full flex-col items-center gap-2 px-1.5 pt-4">
291 <RailButton mode={workspaceMode} to={modeHome("workspace", workspace.slug)} current={here === "workspace"} />
292 {help}
293 <div className="mt-1.5">{account}</div>
294 </div>
295 </nav>
296 );
297}