Skip to content
328 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.

g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them.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, 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 AppKey, type PinnableApp, appOf } 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, code, pins, onToggle, current }: { slug: string; code: boolean; 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} code={code} pins={pins} onToggle={onToggle} onClose={() => setOpen(false)} />
240 </PopoverContent>
241 </Popover>
242 );
243}
244
245/**
246 * The dock down the left (docs/WORKSPACE.md, "Shell"): 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.filter((key) => code || !appOf(key).code);
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 AppKey, 19)}
306 current={here === item.key && !shown.some((key) => at(appOf(key).path(slug)))}
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((key) => {
314 const app = appOf(key);
315 return <DockLink key={key} to={app.path(slug)} label={app.name} icon={appIcon(key, 18)} current={at(app.path(slug))} compact />;
316 })}
317 </div>
318 <div className="flex w-full shrink-0 flex-col items-center px-1.5 pb-1.5">
319 <AppsButton slug={slug} code={code} pins={pins} onToggle={toggle} current={here === "apps"} />
320 </div>
321 <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">
322 <DockLink to={modeHome("people", slug)} label="People" icon={appIcon("people", 19)} current={here === "people"} />
323 <DockLink to={modeHome("workspace", slug)} label="Workspace" icon={appIcon("workspace", 19)} current={here === "workspace"} />
324 <div className="mt-1.5">{account}</div>
325 </div>
326 </nav>
327 );
328}