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