Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
297 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.

The rail itself ships with the navigation it was committed without: the file that draws it was renamed from the dock and left out of the last change.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 { Kbd } from "./ui/kbd";
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 { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarSeparator } from "./ui/sidebar";
24import { type BuiltinApp, type PinnableApp } from "../lib/apps";
25import { cn } from "../lib/cn";
26import { paletteKeyLabel } from "../lib/shortcut";
27import { type ModeKey, homePagePath, modeHome, modeOf } from "../lib/workspace-nav";
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-rail",
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/** One icon of the rail: a link to an app, its name as the hint beside it, and what is unread on it. */
48function RailLink({ to, label, icon, current, badge }: { to: string; label: string; icon: ReactNode; current: boolean; badge?: Item["badge"] }) {
49 return (
50 <SidebarMenuItem>
51 <SidebarMenuButton asChild size="icon" isActive={current} tooltip={label}>
52 <Link to={to} prefetch="intent" aria-current={current ? "page" : undefined} aria-label={badge && badge.count > 0 ? `${label}, ${badge.count} unread` : label}>
53 {icon}
54 {badge && <CountBadge count={badge.count} loud={badge.loud} />}
55 </Link>
56 </SidebarMenuButton>
57 </SidebarMenuItem>
58 );
59}
60
61function displayName(membership: Membership): string {
62 return membership.name?.trim() || membership.slug;
63}
64
65/**
66 * The workspace you are in and the way to another: its avatar, its name
67 * and a chevron, opening your workspaces, a new one, and your own account.
68 * `row` heads the sidebar; `compact` starts the page header's trail when
69 * no sidebar is open.
70 */
71export function WorkspaceSwitcher({ user, workspace, compact = false }: { user: User; workspace: Membership; compact?: boolean }) {
72 return (
73 <DropdownMenu>
74 <DropdownMenuTrigger
75 aria-label={`${displayName(workspace)}: switch workspace`}
76 className={cn(
77 "flex min-w-0 items-center rounded-lg outline-none transition-colors hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:bg-sidebar-accent",
78 compact ? "h-8 shrink-0 gap-1.5 px-1 max-md:h-11 max-sm:gap-1 hover:bg-raised data-[state=open]:bg-raised" : "h-9 grow gap-2 px-1.5",
79 )}
80 >
81 <Avatar name={workspace.slug} image={workspace.avatar} size={compact ? 22 : 26} square />
82 <span className={cn("min-w-0 truncate font-semibold", compact ? "text-sm max-sm:hidden" : "text-[0.875rem]")}>{displayName(workspace)}</span>
83 <ChevronDown size={14} className="shrink-0 text-faint" />
84 </DropdownMenuTrigger>
85 <DropdownMenuContent align="start" className="w-72 p-1.5">
86 <DropdownMenuItem asChild className="gap-3 px-2 py-2">
87 <Link to={`/${workspace.slug}/-/workspace`}>
88 <Avatar name={workspace.slug} image={workspace.avatar} size={36} square />
89 <span className="flex min-w-0 grow flex-col leading-tight">
90 <span className="truncate text-sm font-medium text-fg">{displayName(workspace)}</span>
91 <span className="truncate font-mono text-xs text-muted">g1t.sh/{workspace.slug}</span>
92 <span className="mt-0.5 text-xs text-faint capitalize">{workspace.role}</span>
93 </span>
94 </Link>
95 </DropdownMenuItem>
96 <DropdownMenuSeparator className="my-1.5" />
97 <DropdownMenuLabel>Switch workspace</DropdownMenuLabel>
98 {(user.workspaces ?? []).map((membership) => (
99 <DropdownMenuItem key={membership.slug} asChild>
100 <Link to={homePagePath(membership.slug)}>
101 <Avatar name={membership.slug} image={membership.avatar} size={24} square />
102 <span className="flex min-w-0 grow flex-col leading-tight">
103 <span className="truncate">{displayName(membership)}</span>
104 <span className="truncate font-mono text-[0.6875rem] text-faint">{membership.slug}</span>
105 </span>
106 {membership.slug === workspace.slug ? <Check className="shrink-0 text-accent" /> : <span className="size-4 shrink-0" aria-hidden="true" />}
107 </Link>
108 </DropdownMenuItem>
109 ))}
110 <DropdownMenuItem asChild>
111 <Link to="/workspaces/new">
112 <Plus />
113 New workspace
114 </Link>
115 </DropdownMenuItem>
116 <DropdownMenuSeparator className="my-1.5" />
117 <DropdownMenuLabel>Personal account</DropdownMenuLabel>
118 <DropdownMenuItem asChild>
119 <Link to={`/u/${user.username}`}>
120 <CircleUserRound />
121 <span className="grow">Your profile</span>
122 <span className="font-mono text-xs text-faint">@{shownUsername(user)}</span>
123 </Link>
124 </DropdownMenuItem>
125 <DropdownMenuItem asChild>
126 <Link to="/settings">
127 <Settings />
128 Your settings
129 </Link>
130 </DropdownMenuItem>
131 </DropdownMenuContent>
132 </DropdownMenu>
133 );
134}
135
136/** Keyboard shortcuts, as the account menu lists them. */
137function shortcuts(palette: string, sidebar: string): [string, string][] {
138 return [
139 [palette, "Search, jump anywhere, or run a command"],
140 [sidebar, "Show or hide the sidebar"],
141 ["Esc", "Close a menu, dialog or panel"],
142 ["Enter", "Send a message in Chat"],
143 ["Shift Enter", "A new line in a message"],
144 ["@", "Mention a person or an agent"],
145 ["↑ ↓ then Tab", "Choose a suggestion"],
146 ["Alt ↑ ↓", "Move a pinned project in Code's sidebar"],
147 ];
148}
149
150/** The sidebar's shortcut, as this computer writes it. */
151export function sidebarKeyLabel(platform: string | null): string {
152 return paletteKeyLabel(platform).replace(/K$/, "B");
153}
154
155/** The keyboard's shortcuts, opened from the account menu. */
156export function ShortcutsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
157 const platform = typeof navigator === "undefined" ? null : navigator.platform;
158 return (
159 <Dialog open={open} onOpenChange={onOpenChange}>
160 <DialogContent className="max-w-md">
161 <DialogHeader>
162 <DialogTitle>Keyboard shortcuts</DialogTitle>
163 <DialogDescription>Everywhere in g1t, and in Chat.</DialogDescription>
164 </DialogHeader>
165 <Card asChild tone="plain" radius="lg" divided>
166 <dl>
167 {shortcuts(paletteKeyLabel(platform), sidebarKeyLabel(platform)).map(([key, what]) => (
168 <div key={key} className="flex items-center justify-between gap-4 px-3.5 py-2.5 text-sm">
169 <dt className="text-fg-soft">{what}</dt>
170 <dd>
171 <Kbd className="px-1.5 py-0.5 font-sans text-xs whitespace-nowrap text-fg ring-line-strong">{key}</Kbd>
172 </dd>
173 </div>
174 ))}
175 </dl>
176 </Card>
177 </DialogContent>
178 </Dialog>
179 );
180}
181
182/** The Apps icon at the foot of the rail's apps, and the launcher it opens beside it. */
183function AppsButton({ slug, pins, onToggle, current }: { slug: string; pins: PinnableApp[]; onToggle: (app: PinnableApp) => void; current: boolean }) {
184 const [open, setOpen] = useState(false);
185 return (
186 <Popover open={open} onOpenChange={setOpen}>
187 <SidebarMenuButton asChild size="icon" isActive={current} tooltip="Apps">
188 <PopoverTrigger aria-label="Apps">
189 <LayoutGrid />
190 </PopoverTrigger>
191 </SidebarMenuButton>
192 <PopoverContent side="right" align="end" sideOffset={12} aria-label="Apps" className="w-[23rem] p-2.5">
193 <AppsLauncher slug={slug} pins={pins} onToggle={onToggle} onClose={() => setOpen(false)} />
194 </PopoverContent>
195 </Popover>
196 );
197}
198
199/**
200 * The rail down the window's left edge: a narrow column of icons, each
201 * named by the hint beside it. g1t's mark, which leads to Home; the
Notifications is the bell in the top bar alone: the rail no longer carries it, and the notifications guide points at the bell; a phone keeps it in the bottom bar, which has no bell. Home names who made a deployment, by username or by an agent's handle, instead of showing the account's id, and says someone when identity doesn't know the id. The Apps launcher's skeleton tiles are drawn in the line colour so they show on its raised surface.202 * built-in apps (Home, Chat, Agents, Code and Artifacts) with what is
203 * unread on them (Notifications is the bell in the top bar, not a rail
204 * app); the apps you pinned; the Apps launcher;
The rail itself ships with the navigation it was committed without: the file that draws it was renamed from the dock and left out of the last change.205 * and at its foot People, Workspace and your account. The one you are in
206 * sits on a filled square, and which that is follows the address. A
207 * member without Code access has no Code. From 768px; a phone has the
208 * bottom bar (components/mobile.tsx). Drawn from the sidebar kit
209 * (components/ui/sidebar.tsx), as the mode sidebar beside it is.
210 */
211export function Rail({
212 workspace,
213 pins: saved,
214 unread,
215 account,
216}: {
217 workspace: Membership;
218 /** The apps pinned to this person's rail in this workspace, as saved. */
219 pins: PinnableApp[];
220 unread: { notifications: number; chat: number; mentions: number };
221 account: ReactNode;
222}) {
223 const { pathname } = useLocation();
224 const going = useNavigation().location?.pathname;
225 const path = going ?? pathname;
226 const here = modeOf(path, workspace.slug);
227 const slug = workspace.slug;
228 const code = hasCodeAccess(workspace);
229 const { pins, toggle } = useAppPins(slug, saved);
230 const builtins: Item[] = [
231 { key: "home", label: "Home" },
232 {
233 key: "chat",
234 label: "Chat",
235 badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 },
236 },
Merge branch 'customize' into main: Customize replaces the Marketplace, the Artifacts trash and spaces changes, the Security-free cleanup, with the desktop app's own title bar kept in the shell.237 ...(code ? [{ key: "code" as const, label: "Code" }] : []),
238 // Agents comes after Code: it is where agents are configured, not where the work is.
The rail itself ships with the navigation it was committed without: the file that draws it was renamed from the dock and left out of the last change.239 { key: "agents", label: "Agents" },
240 { key: "artifacts", label: "Artifacts" },
241 ];
242 const shown = pins.map((key) => pinnedApp(key, slug)).filter((app) => app != null);
243 // A pinned app is current on its own page and the pages under it.
244 const at = (to: string) => path === to || path.startsWith(`${to}/`);
245 return (
246 <Sidebar variant="rail" collapsible="none" role="navigation" aria-label="Apps">
247 <SidebarHeader className="h-14 items-center justify-center p-0">
248 <SidebarMenu className="w-auto">
249 <SidebarMenuItem>
250 <SidebarMenuButton asChild size="icon" tooltip="Home">
251 <Link to={homePagePath(slug)} aria-label="g1t: Home">
252 <span className="flex">
253 <Mark tight className="h-[1.125rem] w-auto" />
254 </span>
255 </Link>
256 </SidebarMenuButton>
257 </SidebarMenuItem>
258 </SidebarMenu>
259 </SidebarHeader>
260 <SidebarContent className="items-center gap-0">
261 <SidebarMenu className="w-auto shrink-0 gap-2">
262 {builtins.map((item) => (
263 <RailLink
264 key={item.key}
265 to={modeHome(item.key, slug)}
266 label={item.label}
267 icon={appIcon(item.key as BuiltinApp, 20)}
268 current={here === item.key && !shown.some((app) => at(app.to))}
269 badge={item.badge}
270 />
271 ))}
272 </SidebarMenu>
273 {/* Your pinned apps: as many as you like, scrolling under a fade at either end. */}
274 {shown.length > 0 && (
275 <SidebarMenu className="mt-2 w-auto min-h-0 grow gap-2 overflow-y-auto py-2 [mask-image:linear-gradient(to_bottom,transparent,#000_10px,#000_calc(100%-14px),transparent)] [scrollbar-width:none]">
276 {shown.map((app) => (
277 <RailLink key={app.key} to={app.to} label={app.name} icon={app.icon(20)} current={at(app.to)} />
278 ))}
279 </SidebarMenu>
280 )}
281 </SidebarContent>
282 <SidebarFooter className="items-center gap-0 p-0 pb-3">
283 <SidebarMenu className="w-auto gap-2">
284 <SidebarMenuItem>
285 <AppsButton slug={slug} pins={pins} onToggle={toggle} current={here === "apps"} />
286 </SidebarMenuItem>
287 </SidebarMenu>
288 <SidebarSeparator className="my-2.5 w-7" />
289 <SidebarMenu className="w-auto gap-2">
290 <RailLink to={modeHome("people", slug)} label="People" icon={appIcon("people", 20)} current={here === "people"} />
291 <RailLink to={modeHome("workspace", slug)} label="Workspace" icon={appIcon("workspace", 20)} current={here === "workspace"} />
292 <SidebarMenuItem className="mt-1 flex justify-center">{account}</SidebarMenuItem>
293 </SidebarMenu>
294 </SidebarFooter>
295 </Sidebar>
296 );
297}

This file's history is long; its oldest lines are credited to the oldest commit read.