g1t/apps/web/app/components/shell.tsx

670 lines24,774 bytesCodeBlame

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.

Agents as a team: lifecycle, merge queue, billing and a new shell1import {
2 BookMarked,
3 BookOpen,
4 Check,
5 ChevronsUpDown,
6 CircleDot,
7 Code2,
8 Compass,
9 CornerDownLeft,
10 CreditCard,
11 GitPullRequest,
12 History,
13 KeyRound,
14 Layers,
15 LayoutDashboard,
16 ListTree,
17 Lock,
18 LogOut,
19 Menu,
20 Plus,
21 Search,
22 Settings,
23 Users,
24 X,
25} from "lucide-react";
26import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
27import { Form, Link, NavLink, useLocation, useNavigate, useNavigation, useSubmit } from "react-router";
28
29import type { User } from "@g1t/contracts";
30import { MICROS_PER_DOLLAR } from "@g1t/contracts";
31
32import { Logo } from "./logo";
33import { Avatar } from "./ui";
34import {
35 DropdownMenu,
36 DropdownMenuContent,
37 DropdownMenuItem,
38 DropdownMenuLabel,
39 DropdownMenuSeparator,
40 DropdownMenuTrigger,
41} from "./ui/dropdown-menu";
42
43/** What the sidebar needs, worked out by the root loader for a signed-in person. */
44export type ShellData = {
45 /** The workspace the sidebar is about: the one being looked at, or their first. */
46 workspace: { slug: string; role: "owner" | "member" } | null;
47 /** Its repositories, newest first. */
48 repos: { namespace: string; name: string; isPrivate: boolean }[];
49 /** The repository being looked at, if any, whoever owns it. */
50 repo: {
51 namespace: string;
52 name: string;
53 member: boolean;
54 issues: number;
55 pulls: number;
56 } | null;
57 /** The workspace's agent credit, if billing is on and they may see it. */
58 creditMicros: number | null;
59};
60
61function SidebarLink({
62 to,
63 icon,
64 end,
65 count,
66 also,
67 children,
68}: {
69 to: string;
70 icon: ReactNode;
71 end?: boolean;
72 count?: number;
73 /** Another path prefix under which this link is the current one. */
74 also?: string;
75 children: ReactNode;
76}) {
77 const { pathname } = useLocation();
78 return (
79 <NavLink
80 to={to}
81 end={end}
82 prefetch="intent"
83 className={({ isActive, isPending }) => {
84 const current = isActive || (also != null && pathname.startsWith(also + "/"));
85 return `group flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors ${
86 current
87 ? "bg-raised font-medium text-fg"
88 : isPending
89 ? "bg-raised/60 text-fg"
90 : "text-muted hover:bg-raised/60 hover:text-fg"
91 }`;
92 }}
93 >
94 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
95 <span className="min-w-0 grow truncate">{children}</span>
96 {count != null && count > 0 && (
97 <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
98 {count}
99 </span>
100 )}
101 </NavLink>
102 );
103}
104
105function SidebarGroup({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
106 return (
107 <section className="mt-5">
108 <div className="mb-1 flex items-center justify-between px-2">
109 <h2 className="text-[0.6875rem] font-medium tracking-wider text-faint uppercase">
110 {title}
111 </h2>
112 {action}
113 </div>
114 <div className="space-y-px">{children}</div>
115 </section>
116 );
117}
118
119function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) {
120 const current = shell.workspace?.slug;
121 return (
122 <DropdownMenu>
123 <DropdownMenuTrigger className="flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-left outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised">
124 {current ? (
125 <Avatar name={current} size={26} square />
126 ) : (
127 <span className="size-6.5 rounded-md border border-dashed border-line-strong" />
128 )}
129 <span className="min-w-0 grow">
130 <span className="block truncate text-sm font-semibold">{current ?? "No workspace"}</span>
131 <span className="block truncate text-xs text-faint">
132 {shell.workspace?.role ?? "Create one to start"}
133 </span>
134 </span>
135 <ChevronsUpDown size={14} className="shrink-0 text-faint" />
136 </DropdownMenuTrigger>
137 <DropdownMenuContent align="start" className="w-60">
138 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
139 {(user.workspaces ?? []).map((membership) => (
140 <DropdownMenuItem asChild key={membership.slug}>
141 <Link to={`/${membership.slug}`}>
142 <Avatar name={membership.slug} size={16} square />
143 <span className="grow">{membership.slug}</span>
144 {membership.slug === current && <Check className="text-accent" />}
145 </Link>
146 </DropdownMenuItem>
147 ))}
148 <DropdownMenuSeparator />
149 <DropdownMenuItem asChild>
150 <Link to="/workspaces/new">
151 <Plus />
152 New workspace
153 </Link>
154 </DropdownMenuItem>
155 </DropdownMenuContent>
156 </DropdownMenu>
157 );
158}
159
160function Sidebar({ user, shell }: { user: User; shell: ShellData }) {
161 const ws = shell.workspace;
162 const active = shell.repo;
163 const repoBase = active ? `/${active.namespace}/${active.name}` : null;
164 // The repository being looked at is listed even when it is someone else's.
165 const listed =
166 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
167 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
168 : shell.repos;
169 return (
170 <div className="flex h-full flex-col">
171 <div className="flex h-12 items-center gap-2 px-4">
172 <Link to="/" aria-label="g1t home">
173 <Logo />
174 </Link>
175 </div>
176 <div className="px-2">
177 <WorkspaceSwitcher user={user} shell={shell} />
178 </div>
179 <nav className="min-h-0 grow overflow-y-auto px-2 pb-4">
180 <div className="mt-3 space-y-px">
181 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
182 Mission control
183 </SidebarLink>
184 <SidebarLink to="/explore" icon={<Compass size={15} />}>
185 Explore
186 </SidebarLink>
187 </div>
188
189 {ws && (
190 <SidebarGroup title="Workspace">
191 <SidebarLink to={`/${ws.slug}`} end icon={<LayoutDashboard size={15} />}>
192 Overview
193 </SidebarLink>
194 <SidebarLink to={`/${ws.slug}/-/people`} icon={<Users size={15} />}>
195 People
196 </SidebarLink>
197 <SidebarLink to={`/${ws.slug}/-/tokens`} icon={<KeyRound size={15} />}>
198 Access tokens
199 </SidebarLink>
200 <SidebarLink to={`/${ws.slug}/-/billing`} icon={<CreditCard size={15} />}>
201 Billing
202 </SidebarLink>
203 {ws.role === "owner" && (
204 <SidebarLink to={`/${ws.slug}/-/settings`} icon={<Settings size={15} />}>
205 Settings
206 </SidebarLink>
207 )}
208 </SidebarGroup>
209 )}
210
211 <SidebarGroup
212 title="Repositories"
213 action={
214 ws && (
215 <Link
216 to={`/new?workspace=${ws.slug}`}
217 aria-label="New repository"
218 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
219 >
220 <Plus size={13} />
221 </Link>
222 )
223 }
224 >
225 {listed.length === 0 && (
226 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
227 )}
228 {listed.map((repo) => {
229 const open =
230 active && repo.namespace === active.namespace && repo.name === active.name;
231 const base = `/${repo.namespace}/${repo.name}`;
232 return (
233 <div key={base}>
234 <SidebarLink
235 to={base}
236 end={!open}
237 icon={repo.isPrivate ? <Lock size={15} /> : <BookMarked size={15} />}
238 >
239 <span className="font-mono text-[0.8125rem]">
240 {repo.namespace !== ws?.slug && (
241 <span className="text-faint">{repo.namespace}/</span>
242 )}
243 {repo.name}
244 </span>
245 </SidebarLink>
246 {open && repoBase && (
247 <div className="my-0.5 ml-4 space-y-px border-l border-line pl-2">
248 <SidebarLink to={repoBase} end icon={<Code2 size={14} />} also={`${repoBase}/tree`}>
249 Code
250 </SidebarLink>
251 <SidebarLink
252 to={`${repoBase}/issues`}
253 icon={<CircleDot size={14} />}
254 count={active.issues}
255 >
256 Issues
257 </SidebarLink>
258 <SidebarLink
259 to={`${repoBase}/pulls`}
260 also={`${repoBase}/pull`}
261 icon={<GitPullRequest size={14} />}
262 count={active.pulls}
263 >
264 Pull requests
265 </SidebarLink>
266 <SidebarLink to={`${repoBase}/queue`} icon={<Layers size={14} />}>
267 Merge queue
268 </SidebarLink>
269 <SidebarLink
270 to={`${repoBase}/commits`}
271 also={`${repoBase}/commit`}
272 icon={<History size={14} />}
273 >
274 Commits
275 </SidebarLink>
276 {active.member && (
277 <SidebarLink to={`${repoBase}/plans`} icon={<ListTree size={14} />}>
278 Plan
279 </SidebarLink>
280 )}
281 {active.member && (
282 <SidebarLink to={`${repoBase}/settings`} icon={<Settings size={14} />}>
283 Settings
284 </SidebarLink>
285 )}
286 </div>
287 )}
288 </div>
289 );
290 })}
291 </SidebarGroup>
292 </nav>
293
294 <div className="border-t border-line p-2">
295 {ws && shell.creditMicros != null && (
296 <Link
297 to={`/${ws.slug}/-/billing`}
298 className={`mb-1 flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-raised ${
299 shell.creditMicros <= 0 ? "text-warn" : "text-muted"
300 }`}
301 >
302 <CreditCard size={15} className="shrink-0" />
303 <span className="grow">Agent credit</span>
304 <span className="font-mono text-xs tabular-nums">
305 ${(shell.creditMicros / MICROS_PER_DOLLAR).toFixed(2)}
306 </span>
307 </Link>
308 )}
309 <a
310 href="https://docs.g1t.sh/"
311 className="flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-muted transition-colors hover:bg-raised hover:text-fg"
312 >
313 <BookOpen size={15} className="shrink-0 text-faint" />
314 Documentation
315 </a>
316 </div>
317 </div>
318 );
319}
320
321type Command = { label: string; hint?: string; to: string; icon: ReactNode };
322
323/** Everything the palette can jump to, from what the sidebar already knows. */
324function commandsFor(user: User, shell: ShellData): Command[] {
325 const commands: Command[] = [
326 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
327 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
328 { label: "New repository", to: "/new", icon: <Plus size={15} /> },
329 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
330 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
331 ];
332 const repo = shell.repo;
333 if (repo) {
334 const base = `/${repo.namespace}/${repo.name}`;
335 const name = `${repo.namespace}/${repo.name}`;
336 commands.unshift(
337 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
338 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
339 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
340 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
341 ...(repo.member
342 ? [
343 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
344 { label: "Repository settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
345 ]
346 : []),
347 );
348 }
349 for (const membership of user.workspaces ?? []) {
350 commands.push(
351 { label: membership.slug, hint: "Workspace", to: `/${membership.slug}`, icon: <Avatar name={membership.slug} size={15} square /> },
352 { label: "Billing", hint: membership.slug, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
353 { label: "Access tokens", hint: membership.slug, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
354 );
355 }
356 for (const listed of shell.repos) {
357 commands.push({
358 label: `${listed.namespace}/${listed.name}`,
359 hint: "Repository",
360 to: `/${listed.namespace}/${listed.name}`,
361 icon: <BookMarked size={15} />,
362 });
363 }
364 return commands;
365}
366
367function CommandPalette({
368 open,
369 onClose,
370 commands,
371}: {
372 open: boolean;
373 onClose: () => void;
374 commands: Command[];
375}) {
376 const navigate = useNavigate();
377 const [query, setQuery] = useState("");
378 const [selected, setSelected] = useState(0);
379 const input = useRef<HTMLInputElement>(null);
380 const matches = useMemo(() => {
381 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
382 return commands
383 .filter((command) => {
384 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
385 return words.every((word) => text.includes(word));
386 })
387 .slice(0, 12);
388 }, [commands, query]);
389
390 useEffect(() => {
391 if (!open) return;
392 setQuery("");
393 setSelected(0);
394 input.current?.focus();
395 }, [open]);
396 useEffect(() => setSelected(0), [query]);
397
398 if (!open) return null;
399 const go = (command: Command | undefined) => {
400 if (!command) return;
401 onClose();
402 navigate(command.to);
403 };
404 return (
405 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
406 <div
407 role="dialog"
408 aria-label="Go to"
409 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
410 onClick={(event) => event.stopPropagation()}
411 >
412 <div className="flex items-center gap-3 border-b border-line px-4">
413 <Search size={16} className="text-faint" />
414 <input
415 ref={input}
416 value={query}
417 onChange={(event) => setQuery(event.target.value)}
418 onKeyDown={(event) => {
419 if (event.key === "ArrowDown") {
420 event.preventDefault();
421 setSelected((index) => Math.min(index + 1, matches.length - 1));
422 } else if (event.key === "ArrowUp") {
423 event.preventDefault();
424 setSelected((index) => Math.max(index - 1, 0));
425 } else if (event.key === "Enter") {
426 event.preventDefault();
427 go(matches[selected]);
428 } else if (event.key === "Escape") {
429 onClose();
430 }
431 }}
432 placeholder="Go to a repository, a page, or an action…"
433 autoComplete="off"
434 data-1p-ignore
435 className="h-12 grow border-0 bg-transparent text-sm shadow-none outline-none ring-0 placeholder:text-faint focus:outline-none focus-visible:outline-none"
436 />
437 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
438 </div>
439 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
440 {matches.length === 0 && (
441 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
442 )}
443 {matches.map((command, index) => (
444 <li key={`${command.to}-${command.label}`}>
445 <button
446 type="button"
447 onMouseEnter={() => setSelected(index)}
448 onClick={() => go(command)}
449 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
450 index === selected ? "bg-line text-fg" : "text-muted"
451 }`}
452 >
453 <span className="shrink-0 text-faint">{command.icon}</span>
454 <span className="min-w-0 grow truncate">{command.label}</span>
455 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
456 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
457 </button>
458 </li>
459 ))}
460 </ul>
461 </div>
462 </div>
463 );
464}
465
466/**
467 * A bar across the top of the page while the next one loads, as GitHub
468 * has: it appears at once, creeps towards the end while waiting, then fills
469 * and fades when the page arrives.
470 */
471export function Progress() {
472 const navigation = useNavigation();
473 const busy = navigation.state !== "idle";
474 const [width, setWidth] = useState(0);
475 const [visible, setVisible] = useState(false);
476 useEffect(() => {
477 if (busy) {
478 setVisible(true);
479 setWidth(12);
480 // Slows as it goes, never quite arriving until the page does.
481 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
482 return () => clearInterval(timer);
483 }
484 setWidth((now) => (now > 0 ? 100 : 0));
485 const fade = setTimeout(() => setVisible(false), 250);
486 const reset = setTimeout(() => setWidth(0), 550);
487 return () => {
488 clearTimeout(fade);
489 clearTimeout(reset);
490 };
491 }, [busy]);
492 return (
493 <div
494 aria-hidden="true"
495 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
496 visible ? "opacity-100" : "opacity-0"
497 }`}
498 >
499 <div
500 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
501 style={{ width: `${width}%` }}
502 />
503 </div>
504 );
505}
506
507/**
508 * The signed-in app: a sidebar with the workspace, its repositories and the
509 * sections of the one being looked at; a slim bar with search and the
510 * account; and the page.
511 */
512export function AppShell({
513 user,
514 shell,
515 banner,
516 children,
517}: {
518 user: User;
519 shell: ShellData;
520 banner?: ReactNode;
521 children: ReactNode;
522}) {
523 const submit = useSubmit();
524 const { pathname } = useLocation();
525 const [drawer, setDrawer] = useState(false);
526 const [palette, setPalette] = useState(false);
527 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
528
529 // A new page closes the drawer on small screens.
530 useEffect(() => setDrawer(false), [pathname]);
531 useEffect(() => {
532 const onKey = (event: KeyboardEvent) => {
533 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
534 event.preventDefault();
535 setPalette((open) => !open);
536 }
537 };
538 window.addEventListener("keydown", onKey);
539 return () => window.removeEventListener("keydown", onKey);
540 }, []);
541
542 return (
543 <div className="min-h-screen">
544 <Progress />
545 <aside className="fixed inset-y-0 left-0 z-40 hidden w-64 border-r border-line bg-[color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))] lg:block">
546 <Sidebar user={user} shell={shell} />
547 </aside>
548 {drawer && (
549 <div className="fixed inset-0 z-50 lg:hidden">
550 <button
551 type="button"
552 aria-label="Close menu"
553 className="absolute inset-0 bg-black/60"
554 onClick={() => setDrawer(false)}
555 />
556 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
557 <button
558 type="button"
559 aria-label="Close menu"
560 onClick={() => setDrawer(false)}
561 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
562 >
563 <X size={16} />
564 </button>
565 <Sidebar user={user} shell={shell} />
566 </aside>
567 </div>
568 )}
569
570 <div className="flex min-h-screen flex-col lg:pl-64">
571 <header className="sticky top-0 z-30 flex h-12 items-center gap-2 border-b border-line bg-bg/85 px-3 backdrop-blur sm:px-4">
572 <button
573 type="button"
574 aria-label="Open menu"
575 onClick={() => setDrawer(true)}
576 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
577 >
578 <Menu size={18} />
579 </button>
580 <button
581 type="button"
582 onClick={() => setPalette(true)}
583 className="flex h-8 w-full max-w-md items-center gap-2 rounded-md border border-line bg-surface/60 px-2.5 text-sm text-faint transition-colors hover:border-line-strong hover:text-muted"
584 >
585 <Search size={14} />
586 <span className="grow text-left">Search or jump to…</span>
587 <kbd className="hidden rounded border border-line px-1.5 font-mono text-[0.6875rem] sm:inline">⌘K</kbd>
588 </button>
589 <Form action="/search" className="hidden" />
590 <div className="ml-auto flex items-center gap-1.5">
591 <DropdownMenu>
592 <DropdownMenuTrigger
593 aria-label="Create"
594 className="rounded-md border border-line p-1.5 text-muted outline-none transition-colors hover:border-line-strong hover:text-fg data-[state=open]:bg-raised"
595 >
596 <Plus size={15} />
597 </DropdownMenuTrigger>
598 <DropdownMenuContent align="end">
599 {shell.repo && (
600 <DropdownMenuItem asChild>
601 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
602 <CircleDot />
603 New issue
604 </Link>
605 </DropdownMenuItem>
606 )}
607 {shell.repo?.member && (
608 <DropdownMenuItem asChild>
609 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
610 <ListTree />
611 Plan work
612 </Link>
613 </DropdownMenuItem>
614 )}
615 <DropdownMenuItem asChild>
616 <Link to="/new">
617 <BookMarked />
618 New repository
619 </Link>
620 </DropdownMenuItem>
621 <DropdownMenuItem asChild>
622 <Link to="/workspaces/new">
623 <Users />
624 New workspace
625 </Link>
626 </DropdownMenuItem>
627 </DropdownMenuContent>
628 </DropdownMenu>
629 <DropdownMenu>
630 <DropdownMenuTrigger
631 aria-label="Account menu"
632 className="rounded-md p-1 outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised"
633 >
634 <Avatar name={user.username} size={24} />
635 </DropdownMenuTrigger>
636 <DropdownMenuContent align="end" className="w-56">
637 <DropdownMenuLabel>
638 Signed in as <span className="font-mono font-medium text-fg">{user.username}</span>
639 </DropdownMenuLabel>
640 <DropdownMenuSeparator />
641 <DropdownMenuItem asChild>
642 <Link to="/settings">
643 <Settings />
644 Your settings
645 </Link>
646 </DropdownMenuItem>
647 <DropdownMenuItem asChild>
648 <a href="https://docs.g1t.sh/">
649 <BookOpen />
650 Documentation
651 </a>
652 </DropdownMenuItem>
653 <DropdownMenuSeparator />
654 {/* Submitted from here: the menu closes on select, and a button
655 that has left the page cannot submit a form. */}
656 <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}>
657 <LogOut />
658 Sign out
659 </DropdownMenuItem>
660 </DropdownMenuContent>
661 </DropdownMenu>
662 </div>
663 </header>
664 {banner}
665 <main className="grow">{children}</main>
666 </div>
667 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
668 </div>
669 );
670}