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