flagon-io/g1t

public

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/apps/web/app/components/shell.tsx

834 lines31,969 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 {
A catalogue of model providers, and settings that feel like settings2 ArrowLeft,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request3 BarChart3,
Agents as a team: lifecycle, merge queue, billing and a new shell4 BookMarked,
5 BookOpen,
6 Check,
7 ChevronsUpDown,
A Vercel-like sidebar and a new mark8 Ellipsis,
Agents as a team: lifecycle, merge queue, billing and a new shell9 CircleDot,
10 Code2,
11 Compass,
12 CornerDownLeft,
13 CreditCard,
14 GitPullRequest,
15 History,
16 KeyRound,
17 Layers,
18 LayoutDashboard,
19 ListTree,
20 Lock,
21 LogOut,
22 Menu,
23 Plus,
24 Search,
Integrations: your own model provider, alerts that open issues, tickets agents read25 Plug,
Agents as a team: lifecycle, merge queue, billing and a new shell26 Settings,
27 Users,
28 X,
29} from "lucide-react";
30import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
31import { Form, Link, NavLink, useLocation, useNavigate, useNavigation, useSubmit } from "react-router";
32
33import type { User } from "@g1t/contracts";
34import { MICROS_PER_DOLLAR } from "@g1t/contracts";
35
A Vercel-like sidebar and a new mark36import { Mark } from "./logo";
Agents as a team: lifecycle, merge queue, billing and a new shell37import { Avatar } from "./ui";
38import {
39 DropdownMenu,
40 DropdownMenuContent,
41 DropdownMenuItem,
42 DropdownMenuLabel,
43 DropdownMenuSeparator,
44 DropdownMenuTrigger,
45} from "./ui/dropdown-menu";
46
47/** What the sidebar needs, worked out by the root loader for a signed-in person. */
48export type ShellData = {
49 /** The workspace the sidebar is about: the one being looked at, or their first. */
50 workspace: { slug: string; role: "owner" | "member" } | null;
51 /** Its repositories, newest first. */
52 repos: { namespace: string; name: string; isPrivate: boolean }[];
53 /** The repository being looked at, if any, whoever owns it. */
54 repo: {
55 namespace: string;
56 name: string;
57 member: boolean;
58 issues: number;
59 pulls: number;
60 } | null;
61 /** The workspace's agent credit, if billing is on and they may see it. */
62 creditMicros: number | null;
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request63 /** What its agents have cost since the start of the month. */
64 monthSpentMicros: number | null;
Agents as a team: lifecycle, merge queue, billing and a new shell65};
66
67function SidebarLink({
68 to,
69 icon,
70 end,
71 count,
72 also,
73 children,
74}: {
75 to: string;
76 icon: ReactNode;
77 end?: boolean;
78 count?: number;
79 /** Another path prefix under which this link is the current one. */
80 also?: string;
81 children: ReactNode;
82}) {
83 const { pathname } = useLocation();
84 return (
85 <NavLink
86 to={to}
87 end={end}
88 prefetch="intent"
89 className={({ isActive, isPending }) => {
90 const current = isActive || (also != null && pathname.startsWith(also + "/"));
A Vercel-like sidebar and a new mark91 return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
Agents as a team: lifecycle, merge queue, billing and a new shell92 current
93 ? "bg-raised font-medium text-fg"
94 : isPending
95 ? "bg-raised/60 text-fg"
96 : "text-muted hover:bg-raised/60 hover:text-fg"
97 }`;
98 }}
99 >
100 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
101 <span className="min-w-0 grow truncate">{children}</span>
102 {count != null && count > 0 && (
103 <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
104 {count}
105 </span>
106 )}
107 </NavLink>
108 );
109}
110
111function SidebarGroup({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
112 return (
A Vercel-like sidebar and a new mark113 <section className="mt-6">
114 <div className="mb-1 flex h-6 items-center justify-between px-2">
115 <h2 className="text-xs font-medium text-faint">{title}</h2>
Agents as a team: lifecycle, merge queue, billing and a new shell116 {action}
117 </div>
118 <div className="space-y-px">{children}</div>
119 </section>
120 );
121}
122
123function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) {
124 const current = shell.workspace?.slug;
125 return (
126 <DropdownMenu>
A Vercel-like sidebar and a new mark127 {/* The name goes to the workspace; only the arrows switch it. */}
128 <Link
129 to={current ? `/${current}` : "/workspaces/new"}
130 className="flex h-8 min-w-0 items-center gap-2 rounded-md px-1.5 transition-colors hover:bg-raised"
131 >
Agents as a team: lifecycle, merge queue, billing and a new shell132 {current ? (
A Vercel-like sidebar and a new mark133 <Avatar name={current} size={20} square />
Agents as a team: lifecycle, merge queue, billing and a new shell134 ) : (
A Vercel-like sidebar and a new mark135 <span className="size-5 rounded-md border border-dashed border-line-strong" />
Agents as a team: lifecycle, merge queue, billing and a new shell136 )}
A Vercel-like sidebar and a new mark137 <span className="min-w-0 truncate text-sm font-medium">{current ?? "Choose a workspace"}</span>
138 {shell.workspace && (
139 <span className="shrink-0 rounded-full bg-raised px-1.5 py-px text-[0.625rem] font-medium text-muted ring-1 ring-line capitalize">
140 {shell.workspace.role}
Agents as a team: lifecycle, merge queue, billing and a new shell141 </span>
A Vercel-like sidebar and a new mark142 )}
143 </Link>
144 <DropdownMenuTrigger
145 aria-label="Switch workspace"
146 className="ml-auto flex h-8 w-6 shrink-0 items-center justify-center rounded-md text-faint outline-none transition-colors hover:bg-raised hover:text-fg data-[state=open]:bg-raised data-[state=open]:text-fg"
147 >
148 <ChevronsUpDown size={14} />
Agents as a team: lifecycle, merge queue, billing and a new shell149 </DropdownMenuTrigger>
150 <DropdownMenuContent align="start" className="w-60">
151 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
152 {(user.workspaces ?? []).map((membership) => (
153 <DropdownMenuItem asChild key={membership.slug}>
154 <Link to={`/${membership.slug}`}>
155 <Avatar name={membership.slug} size={16} square />
156 <span className="grow">{membership.slug}</span>
157 {membership.slug === current && <Check className="text-accent" />}
158 </Link>
159 </DropdownMenuItem>
160 ))}
161 <DropdownMenuSeparator />
162 <DropdownMenuItem asChild>
163 <Link to="/workspaces/new">
164 <Plus />
165 New workspace
166 </Link>
167 </DropdownMenuItem>
168 </DropdownMenuContent>
169 </DropdownMenu>
170 );
171}
172
A Vercel-like sidebar and a new mark173/** This month's spend against what is left, as Vercel shows a plan's usage. */
174function UsageCard({ slug, shell }: { slug: string; shell: ShellData }) {
175 if (shell.monthSpentMicros == null) return null;
176 const spent = shell.monthSpentMicros;
177 const left = shell.creditMicros;
178 const share = left != null && spent + left > 0 ? Math.min(1, spent / (spent + Math.max(left, 0))) : 0;
179 return (
180 <Link
181 to={`/${slug}/-/usage`}
182 className="block rounded-lg bg-surface p-3 ring-1 ring-line transition-colors hover:ring-line-strong"
183 >
184 <span className="flex items-baseline justify-between text-xs">
185 <span className="font-medium text-fg">Usage</span>
186 <span className="text-faint">this month</span>
187 </span>
188 <span className="mt-2 flex items-baseline justify-between">
189 <span className="font-mono text-sm tabular-nums">${(spent / MICROS_PER_DOLLAR).toFixed(2)}</span>
190 {left != null && (
191 <span className={`text-xs ${left <= 0 ? "text-warn" : "text-faint"}`}>
192 ${(left / MICROS_PER_DOLLAR).toFixed(2)} left
193 </span>
194 )}
195 </span>
196 <span className="mt-2 block h-1 overflow-hidden rounded-full bg-raised">
197 <span
198 className={`block h-full rounded-full ${left != null && left <= 0 ? "bg-warn" : "bg-accent"}`}
199 style={{ width: `${Math.max(share * 100, spent > 0 ? 3 : 0)}%` }}
200 />
201 </span>
202 </Link>
203 );
204}
205
206function AccountMenu({ user }: { user: User }) {
207 const submit = useSubmit();
208 return (
209 <DropdownMenu>
210 <DropdownMenuTrigger className="flex h-10 w-full items-center gap-2.5 rounded-md px-2 text-left outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised">
211 <Avatar name={user.username} size={22} />
212 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
213 <Ellipsis size={15} className="shrink-0 text-faint" />
214 </DropdownMenuTrigger>
215 <DropdownMenuContent align="start" side="top" className="w-56">
216 <DropdownMenuLabel>
217 Signed in as <span className="font-mono font-medium text-fg">{user.username}</span>
218 </DropdownMenuLabel>
219 <DropdownMenuSeparator />
220 <DropdownMenuItem asChild>
221 <Link to="/settings">
222 <Settings />
223 Your settings
224 </Link>
225 </DropdownMenuItem>
226 <DropdownMenuItem asChild>
227 <a href="https://docs.g1t.sh/">
228 <BookOpen />
229 Documentation
230 </a>
231 </DropdownMenuItem>
232 <DropdownMenuSeparator />
233 {/* Submitted from here: the menu closes on select, and a button
234 that has left the page cannot submit a form. */}
235 <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}>
236 <LogOut />
237 Sign out
238 </DropdownMenuItem>
239 </DropdownMenuContent>
240 </DropdownMenu>
241 );
242}
243
A catalogue of model providers, and settings that feel like settings244/** A workspace's settings pages, which the sidebar slides over to. */
245const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations)(\/|$)/;
246
247/** How the sidebar's two menus slide past each other. */
248const SLIDE = "absolute inset-0 overflow-y-auto px-2 pb-4 transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none";
249
250/** A workspace's settings, as the sidebar shows them in place of everything else. */
251function SettingsMenu({ slug, owner, open }: { slug: string; owner: boolean; open: boolean }) {
252 return (
253 <nav
254 aria-label="Workspace settings"
255 inert={!open}
256 className={`${SLIDE} ${open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}
257 >
258 <Link
259 to={`/${slug}`}
260 className="group mt-3 flex h-8 items-center gap-2 rounded-md px-2 text-[0.8125rem] text-muted transition-colors hover:bg-raised/60 hover:text-fg"
261 >
262 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
263 <span className="truncate">{slug}</span>
264 </Link>
265 <SidebarGroup title="Settings">
266 {owner && (
267 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
268 General
269 </SidebarLink>
270 )}
271 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
272 Members
273 </SidebarLink>
274 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
275 Billing
276 </SidebarLink>
277 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}>
278 Integrations
279 </SidebarLink>
280 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
281 Access tokens
282 </SidebarLink>
283 </SidebarGroup>
284 </nav>
285 );
286}
287
A Vercel-like sidebar and a new mark288function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
Agents as a team: lifecycle, merge queue, billing and a new shell289 const ws = shell.workspace;
A catalogue of model providers, and settings that feel like settings290 const { pathname } = useLocation();
291 const going = useNavigation().location?.pathname;
292 // On a workspace's settings page, or on the way to one, its settings take
293 // the sidebar over.
294 const inSettings = ws != null && SETTINGS_PAGE.exec(going ?? pathname)?.[1]?.toLowerCase() === ws.slug;
Agents as a team: lifecycle, merge queue, billing and a new shell295 const active = shell.repo;
296 const repoBase = active ? `/${active.namespace}/${active.name}` : null;
297 // The repository being looked at is listed even when it is someone else's.
298 const listed =
299 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
300 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
301 : shell.repos;
302 return (
303 <div className="flex h-full flex-col">
A Vercel-like sidebar and a new mark304 {/* The same height and rule as the top bar, so the two read as one line. */}
305 <div className="flex h-12 shrink-0 items-center gap-1 border-b border-line px-2">
306 <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
307 <Mark className="size-5" />
Agents as a team: lifecycle, merge queue, billing and a new shell308 </Link>
A Vercel-like sidebar and a new mark309 <span className="shrink-0 text-line-strong" aria-hidden="true">
310 /
311 </span>
312 <WorkspaceSwitcher user={user} shell={shell} />
Agents as a team: lifecycle, merge queue, billing and a new shell313 </div>
A Vercel-like sidebar and a new mark314 <div className="px-2 pt-3">
315 <button
316 type="button"
317 onClick={onFind}
318 className="flex h-8 w-full items-center gap-2 rounded-md bg-surface px-2.5 text-[0.8125rem] text-faint ring-1 ring-line transition-colors hover:text-muted hover:ring-line-strong"
319 >
320 <Search size={14} />
321 <span className="grow text-left">Find…</span>
322 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
323 </button>
Agents as a team: lifecycle, merge queue, billing and a new shell324 </div>
A catalogue of model providers, and settings that feel like settings325 <div className="relative min-h-0 grow overflow-hidden">
326 <nav
327 aria-label="g1t"
328 inert={inSettings}
329 className={`${SLIDE} ${inSettings ? "pointer-events-none -translate-x-1/3 opacity-0" : "translate-x-0 opacity-100"}`}
330 >
Agents as a team: lifecycle, merge queue, billing and a new shell331 <div className="mt-3 space-y-px">
332 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
333 Mission control
334 </SidebarLink>
335 <SidebarLink to="/explore" icon={<Compass size={15} />}>
336 Explore
337 </SidebarLink>
338 </div>
339
340 {ws && (
341 <SidebarGroup title="Workspace">
342 <SidebarLink to={`/${ws.slug}`} end icon={<LayoutDashboard size={15} />}>
343 Overview
344 </SidebarLink>
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request345 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
346 Usage
347 </SidebarLink>
A catalogue of model providers, and settings that feel like settings348 {/* Everything else about the workspace lives in its settings, and only there. */}
349 <SidebarLink
350 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
351 icon={<Settings size={15} />}
352 >
353 Settings
Integrations: your own model provider, alerts that open issues, tickets agents read354 </SidebarLink>
Agents as a team: lifecycle, merge queue, billing and a new shell355 </SidebarGroup>
356 )}
357
358 <SidebarGroup
359 title="Repositories"
360 action={
361 ws && (
362 <Link
363 to={`/new?workspace=${ws.slug}`}
364 aria-label="New repository"
365 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
366 >
367 <Plus size={13} />
368 </Link>
369 )
370 }
371 >
372 {listed.length === 0 && (
373 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
374 )}
375 {listed.map((repo) => {
376 const open =
377 active && repo.namespace === active.namespace && repo.name === active.name;
378 const base = `/${repo.namespace}/${repo.name}`;
379 return (
380 <div key={base}>
381 <SidebarLink
382 to={base}
383 end={!open}
384 icon={repo.isPrivate ? <Lock size={15} /> : <BookMarked size={15} />}
385 >
386 <span className="font-mono text-[0.8125rem]">
387 {repo.namespace !== ws?.slug && (
388 <span className="text-faint">{repo.namespace}/</span>
389 )}
390 {repo.name}
391 </span>
392 </SidebarLink>
393 {open && repoBase && (
394 <div className="my-0.5 ml-4 space-y-px border-l border-line pl-2">
395 <SidebarLink to={repoBase} end icon={<Code2 size={14} />} also={`${repoBase}/tree`}>
396 Code
397 </SidebarLink>
398 <SidebarLink
399 to={`${repoBase}/issues`}
400 icon={<CircleDot size={14} />}
401 count={active.issues}
402 >
403 Issues
404 </SidebarLink>
405 <SidebarLink
406 to={`${repoBase}/pulls`}
407 also={`${repoBase}/pull`}
408 icon={<GitPullRequest size={14} />}
409 count={active.pulls}
410 >
411 Pull requests
412 </SidebarLink>
413 <SidebarLink to={`${repoBase}/queue`} icon={<Layers size={14} />}>
414 Merge queue
415 </SidebarLink>
416 <SidebarLink
417 to={`${repoBase}/commits`}
418 also={`${repoBase}/commit`}
419 icon={<History size={14} />}
420 >
421 Commits
422 </SidebarLink>
423 {active.member && (
424 <SidebarLink to={`${repoBase}/plans`} icon={<ListTree size={14} />}>
425 Plan
426 </SidebarLink>
427 )}
428 {active.member && (
429 <SidebarLink to={`${repoBase}/settings`} icon={<Settings size={14} />}>
430 Settings
431 </SidebarLink>
432 )}
433 </div>
434 )}
435 </div>
436 );
437 })}
438 </SidebarGroup>
439 </nav>
A catalogue of model providers, and settings that feel like settings440 {ws && <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />}
441 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell442
A Vercel-like sidebar and a new mark443 <div className="space-y-2 p-2">
444 {ws && <UsageCard slug={ws.slug} shell={shell} />}
445 <AccountMenu user={user} />
446 </div>
447 </div>
448 );
449}
450
451/** Words for the sections a path can end in. */
452const SECTIONS: Record<string, string> = {
453 issues: "Issues",
454 pulls: "Pull requests",
455 queue: "Merge queue",
456 commits: "Commits",
457 plans: "Plan",
458 settings: "Settings",
A catalogue of model providers, and settings that feel like settings459 people: "Members",
A Vercel-like sidebar and a new mark460 tokens: "Access tokens",
461 usage: "Usage",
462 billing: "Billing",
A catalogue of model providers, and settings that feel like settings463 integrations: "Integrations",
A Vercel-like sidebar and a new mark464 tree: "Code",
465 blob: "Code",
466};
467
468/** Where the page is, as a trail of links: workspace / repository / section. */
469function Breadcrumbs({ pathname }: { pathname: string }) {
470 const parts = pathname.split("/").filter(Boolean);
471 const reserved = ["settings", "explore", "new", "search", "workspaces"];
472 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
473 if (reserved.includes(parts[0]!)) {
474 const words: Record<string, string> = {
475 settings: "Your settings",
476 explore: "Explore",
477 new: "New repository",
478 search: "Search",
479 workspaces: "New workspace",
480 };
481 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
482 }
483 const [owner, second, third, fourth] = parts;
484 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
485 if (second === "-") {
A catalogue of model providers, and settings that feel like settings486 const page = `/${owner}/-/${third}`;
487 if (third && SETTINGS_PAGE.test(page)) {
488 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
489 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
490 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
A Vercel-like sidebar and a new mark491 } else if (second) {
492 const repo = `/${owner}/${second}`;
493 trail.push({ label: second, to: repo, mono: true });
494 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
495 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
496 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
497 else if (third && SECTIONS[third]) trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
498 }
499 return (
500 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
501 {trail.map((crumb, index) => (
502 <span key={crumb.to} className="flex min-w-0 items-center gap-1.5">
503 {index > 0 && <span className="text-line-strong">/</span>}
Agents as a team: lifecycle, merge queue, billing and a new shell504 <Link
A Vercel-like sidebar and a new mark505 to={crumb.to}
506 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
507 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
508 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
Agents as a team: lifecycle, merge queue, billing and a new shell509 >
A Vercel-like sidebar and a new mark510 {crumb.label}
Agents as a team: lifecycle, merge queue, billing and a new shell511 </Link>
A Vercel-like sidebar and a new mark512 </span>
513 ))}
514 </nav>
Agents as a team: lifecycle, merge queue, billing and a new shell515 );
516}
517
518type Command = { label: string; hint?: string; to: string; icon: ReactNode };
519
520/** Everything the palette can jump to, from what the sidebar already knows. */
521function commandsFor(user: User, shell: ShellData): Command[] {
522 const commands: Command[] = [
523 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
524 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
525 { label: "New repository", to: "/new", icon: <Plus size={15} /> },
526 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
527 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
528 ];
529 const repo = shell.repo;
530 if (repo) {
531 const base = `/${repo.namespace}/${repo.name}`;
532 const name = `${repo.namespace}/${repo.name}`;
533 commands.unshift(
534 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
535 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
536 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
537 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
538 ...(repo.member
539 ? [
540 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
541 { label: "Repository settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
542 ]
543 : []),
544 );
545 }
546 for (const membership of user.workspaces ?? []) {
547 commands.push(
548 { label: membership.slug, hint: "Workspace", to: `/${membership.slug}`, icon: <Avatar name={membership.slug} size={15} square /> },
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request549 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
A catalogue of model providers, and settings that feel like settings550 { label: "Billing", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
551 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
552 { label: "Integrations", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/integrations`, icon: <Plug size={15} /> },
Agents as a team: lifecycle, merge queue, billing and a new shell553 );
554 }
555 for (const listed of shell.repos) {
556 commands.push({
557 label: `${listed.namespace}/${listed.name}`,
558 hint: "Repository",
559 to: `/${listed.namespace}/${listed.name}`,
560 icon: <BookMarked size={15} />,
561 });
562 }
563 return commands;
564}
565
566function CommandPalette({
567 open,
568 onClose,
569 commands,
570}: {
571 open: boolean;
572 onClose: () => void;
573 commands: Command[];
574}) {
575 const navigate = useNavigate();
576 const [query, setQuery] = useState("");
577 const [selected, setSelected] = useState(0);
578 const input = useRef<HTMLInputElement>(null);
579 const matches = useMemo(() => {
580 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
581 return commands
582 .filter((command) => {
583 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
584 return words.every((word) => text.includes(word));
585 })
586 .slice(0, 12);
587 }, [commands, query]);
588
589 useEffect(() => {
590 if (!open) return;
591 setQuery("");
592 setSelected(0);
593 input.current?.focus();
594 }, [open]);
595 useEffect(() => setSelected(0), [query]);
596
597 if (!open) return null;
598 const go = (command: Command | undefined) => {
599 if (!command) return;
600 onClose();
601 navigate(command.to);
602 };
603 return (
604 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
605 <div
606 role="dialog"
607 aria-label="Go to"
608 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
609 onClick={(event) => event.stopPropagation()}
610 >
611 <div className="flex items-center gap-3 border-b border-line px-4">
612 <Search size={16} className="text-faint" />
613 <input
614 ref={input}
615 value={query}
616 onChange={(event) => setQuery(event.target.value)}
617 onKeyDown={(event) => {
618 if (event.key === "ArrowDown") {
619 event.preventDefault();
620 setSelected((index) => Math.min(index + 1, matches.length - 1));
621 } else if (event.key === "ArrowUp") {
622 event.preventDefault();
623 setSelected((index) => Math.max(index - 1, 0));
624 } else if (event.key === "Enter") {
625 event.preventDefault();
626 go(matches[selected]);
627 } else if (event.key === "Escape") {
628 onClose();
629 }
630 }}
631 placeholder="Go to a repository, a page, or an action…"
632 autoComplete="off"
633 data-1p-ignore
634 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"
635 />
636 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
637 </div>
638 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
639 {matches.length === 0 && (
640 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
641 )}
642 {matches.map((command, index) => (
643 <li key={`${command.to}-${command.label}`}>
644 <button
645 type="button"
646 onMouseEnter={() => setSelected(index)}
647 onClick={() => go(command)}
648 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
649 index === selected ? "bg-line text-fg" : "text-muted"
650 }`}
651 >
652 <span className="shrink-0 text-faint">{command.icon}</span>
653 <span className="min-w-0 grow truncate">{command.label}</span>
654 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
655 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
656 </button>
657 </li>
658 ))}
659 </ul>
660 </div>
661 </div>
662 );
663}
664
665/**
666 * A bar across the top of the page while the next one loads, as GitHub
667 * has: it appears at once, creeps towards the end while waiting, then fills
668 * and fades when the page arrives.
669 */
670export function Progress() {
671 const navigation = useNavigation();
672 const busy = navigation.state !== "idle";
673 const [width, setWidth] = useState(0);
674 const [visible, setVisible] = useState(false);
675 useEffect(() => {
676 if (busy) {
677 setVisible(true);
678 setWidth(12);
679 // Slows as it goes, never quite arriving until the page does.
680 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
681 return () => clearInterval(timer);
682 }
683 setWidth((now) => (now > 0 ? 100 : 0));
684 const fade = setTimeout(() => setVisible(false), 250);
685 const reset = setTimeout(() => setWidth(0), 550);
686 return () => {
687 clearTimeout(fade);
688 clearTimeout(reset);
689 };
690 }, [busy]);
691 return (
692 <div
693 aria-hidden="true"
694 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
695 visible ? "opacity-100" : "opacity-0"
696 }`}
697 >
698 <div
699 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
700 style={{ width: `${width}%` }}
701 />
702 </div>
703 );
704}
705
706/**
707 * The signed-in app: a sidebar with the workspace, its repositories and the
708 * sections of the one being looked at; a slim bar with search and the
709 * account; and the page.
710 */
711export function AppShell({
712 user,
713 shell,
714 banner,
715 children,
716}: {
717 user: User;
718 shell: ShellData;
719 banner?: ReactNode;
720 children: ReactNode;
721}) {
722 const submit = useSubmit();
723 const { pathname } = useLocation();
724 const [drawer, setDrawer] = useState(false);
725 const [palette, setPalette] = useState(false);
726 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
727
728 // A new page closes the drawer on small screens.
729 useEffect(() => setDrawer(false), [pathname]);
730 useEffect(() => {
731 const onKey = (event: KeyboardEvent) => {
732 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
733 event.preventDefault();
734 setPalette((open) => !open);
735 }
736 };
737 window.addEventListener("keydown", onKey);
738 return () => window.removeEventListener("keydown", onKey);
739 }, []);
740
741 return (
742 <div className="min-h-screen">
743 <Progress />
744 <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">
A Vercel-like sidebar and a new mark745 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell746 </aside>
747 {drawer && (
748 <div className="fixed inset-0 z-50 lg:hidden">
749 <button
750 type="button"
751 aria-label="Close menu"
752 className="absolute inset-0 bg-black/60"
753 onClick={() => setDrawer(false)}
754 />
755 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
756 <button
757 type="button"
758 aria-label="Close menu"
759 onClick={() => setDrawer(false)}
760 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
761 >
762 <X size={16} />
763 </button>
A Vercel-like sidebar and a new mark764 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell765 </aside>
766 </div>
767 )}
768
769 <div className="flex min-h-screen flex-col lg:pl-64">
770 <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">
771 <button
772 type="button"
773 aria-label="Open menu"
774 onClick={() => setDrawer(true)}
775 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
776 >
777 <Menu size={18} />
778 </button>
A Vercel-like sidebar and a new mark779 <Breadcrumbs pathname={pathname} />
Agents as a team: lifecycle, merge queue, billing and a new shell780 <div className="ml-auto flex items-center gap-1.5">
A Vercel-like sidebar and a new mark781 <a
782 href="https://docs.g1t.sh/"
783 className="hidden rounded-md px-2 py-1 text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
784 >
785 Docs
786 </a>
Agents as a team: lifecycle, merge queue, billing and a new shell787 <DropdownMenu>
788 <DropdownMenuTrigger
789 aria-label="Create"
A Vercel-like sidebar and a new mark790 className="flex h-8 items-center gap-1.5 rounded-md bg-fg px-2.5 text-[0.8125rem] font-medium text-bg outline-none transition-colors hover:bg-white"
Agents as a team: lifecycle, merge queue, billing and a new shell791 >
A Vercel-like sidebar and a new mark792 <Plus size={14} />
793 New
Agents as a team: lifecycle, merge queue, billing and a new shell794 </DropdownMenuTrigger>
795 <DropdownMenuContent align="end">
796 {shell.repo && (
797 <DropdownMenuItem asChild>
798 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
799 <CircleDot />
800 New issue
801 </Link>
802 </DropdownMenuItem>
803 )}
804 {shell.repo?.member && (
805 <DropdownMenuItem asChild>
806 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
807 <ListTree />
808 Plan work
809 </Link>
810 </DropdownMenuItem>
811 )}
812 <DropdownMenuItem asChild>
813 <Link to="/new">
814 <BookMarked />
815 New repository
816 </Link>
817 </DropdownMenuItem>
818 <DropdownMenuItem asChild>
819 <Link to="/workspaces/new">
820 <Users />
821 New workspace
822 </Link>
823 </DropdownMenuItem>
824 </DropdownMenuContent>
825 </DropdownMenu>
826 </div>
827 </header>
828 {banner}
829 <main className="grow">{children}</main>
830 </div>
831 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
832 </div>
833 );
834}