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