A flat project menu with Code first, views as tabs, and Stripe for every owner
The project menu is flat, in the order people use it: Code, Issues, Pull requests, Agents, Workflows, Deployments, Security, Insights, Settings, with the project's own row as its overview. A page with more than one view shows them as tabs across its top (Files, Commits and what is coming under Code; Issues and Outcomes; Pull requests and the merge queue; Deployments, environments, logs and the rest), Soon views included, the way GitHub and Vercel do. What spans projects belongs to the workspace, not to one project: Boards (like GitHub Projects), Roadmap, Packages and the Agent fleet, under Across projects in the workspace's menu, each with its page. Billing opens with Stripe for every owner, comped workspaces included: card, invoices, receipts and billing details.
| 1 | 1 | import { | |
| 2 | 2 | ArrowLeft, | |
| 3 | − | ChevronRight, | |
| 3 | + | GanttChart, | |
| 4 | + | KanbanSquare, | |
| 5 | + | Package, | |
| 6 | + | Sparkles, | |
| 4 | 7 | BarChart3, | |
| 5 | 8 | BookMarked, | |
| 6 | 9 | BookOpen, | |
| 30 | 33 | Users, | |
| 31 | 34 | Webhook, | |
| 32 | 35 | PlayCircle, | |
| 33 | − | Activity, | |
| 34 | 36 | Bot, | |
| 35 | − | Kanban, | |
| 36 | 37 | ShieldCheck, | |
| 37 | 38 | Rocket, | |
| 38 | 39 | X, | |
| 149 | 150 | } | |
| 150 | 151 | ||
| 151 | 152 | /** | |
| 152 | − | * A section of the project menu that opens to show its pages, as GitLab's | |
| 153 | − | * does: one row with an icon, and its pages indented under it. It opens | |
| 154 | − | * by itself when one of its pages is the current one, and otherwise | |
| 155 | − | * remembers whether it was left open. | |
| 153 | + | * A page that is coming, as a link to its roadmap page: faint, with Soon, | |
| 154 | + | * and marked current on any of its section's Soon pages. | |
| 156 | 155 | */ | |
| 157 | − | function SidebarSection({ | |
| 158 | − | title, | |
| 159 | − | icon, | |
| 160 | − | paths = [], | |
| 161 | − | soon = false, | |
| 162 | − | children, | |
| 163 | − | }: { | |
| 164 | − | title: string; | |
| 165 | − | icon: ReactNode; | |
| 166 | − | /** Path prefixes of its pages: one being current opens it. */ | |
| 167 | − | paths?: string[]; | |
| 168 | − | /** Nothing in it is built yet. */ | |
| 169 | − | soon?: boolean; | |
| 170 | − | children: ReactNode; | |
| 171 | − | }) { | |
| 172 | − | const { pathname } = useLocation(); | |
| 173 | − | const current = paths.some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/")); | |
| 174 | − | const key = `g1t.sidebar.${title}`; | |
| 175 | − | const [open, setOpen] = useState<boolean>(current); | |
| 176 | − | // What the person left it as, once the page is in the browser. | |
| 177 | − | useEffect(() => { | |
| 178 | − | if (current) { | |
| 179 | − | setOpen(true); | |
| 180 | − | return; | |
| 181 | − | } | |
| 182 | − | try { | |
| 183 | − | const saved = localStorage.getItem(key); | |
| 184 | − | if (saved != null) setOpen(saved === "1"); | |
| 185 | − | } catch { | |
| 186 | − | // No storage: it stays as it is. | |
| 187 | − | } | |
| 188 | − | }, [current, key]); | |
| 189 | − | const toggle = () => { | |
| 190 | − | const next = !open; | |
| 191 | − | setOpen(next); | |
| 192 | − | try { | |
| 193 | − | localStorage.setItem(key, next ? "1" : "0"); | |
| 194 | − | } catch { | |
| 195 | − | // Remembered for this page only. | |
| 196 | − | } | |
| 197 | − | }; | |
| 198 | − | return ( | |
| 199 | − | <section> | |
| 200 | − | <button | |
| 201 | − | type="button" | |
| 202 | − | onClick={toggle} | |
| 203 | − | aria-expanded={open} | |
| 204 | − | className={`group flex h-8 w-full items-center gap-2.5 rounded-md px-2 text-left text-[0.8125rem] transition-colors hover:bg-raised/60 ${ | |
| 205 | − | current ? "text-fg" : soon ? "text-faint hover:text-muted" : "text-muted hover:text-fg" | |
| 206 | − | }`} | |
| 207 | − | > | |
| 208 | − | <span className={`shrink-0 ${current ? "text-muted" : "text-faint group-hover:text-muted"}`}>{icon}</span> | |
| 209 | − | <span className={`min-w-0 grow truncate ${current ? "font-medium" : ""}`}>{title}</span> | |
| 210 | − | {soon && !open && ( | |
| 211 | − | <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line"> | |
| 212 | − | Soon | |
| 213 | − | </span> | |
| 214 | − | )} | |
| 215 | − | <ChevronRight | |
| 216 | − | size={14} | |
| 217 | − | className={`shrink-0 text-faint transition-transform duration-150 ${open ? "rotate-90" : ""}`} | |
| 218 | − | aria-hidden="true" | |
| 219 | − | /> | |
| 220 | − | </button> | |
| 221 | − | {open && ( | |
| 222 | − | <div className="relative mt-px mb-1 space-y-px before:absolute before:top-1 before:bottom-1 before:left-[1.1875rem] before:w-px before:bg-line"> | |
| 223 | − | {children} | |
| 224 | − | </div> | |
| 225 | − | )} | |
| 226 | − | </section> | |
| 227 | − | ); | |
| 228 | − | } | |
| 229 | − | ||
| 230 | − | /** A page inside a section: indented, without an icon of its own. */ | |
| 231 | − | function SidebarSubLink({ | |
| 156 | + | function SidebarSoonLink({ | |
| 232 | 157 | to, | |
| 233 | − | end, | |
| 234 | − | count, | |
| 235 | 158 | also, | |
| 159 | + | icon, | |
| 160 | + | about, | |
| 236 | 161 | children, | |
| 237 | 162 | }: { | |
| 238 | 163 | to: string; | |
| 239 | − | end?: boolean; | |
| 240 | − | count?: number; | |
| 241 | − | also?: string | string[]; | |
| 164 | + | also?: string[]; | |
| 165 | + | icon: ReactNode; | |
| 166 | + | about: string; | |
| 242 | 167 | children: ReactNode; | |
| 243 | 168 | }) { | |
| 244 | 169 | const { pathname } = useLocation(); | |
| 245 | 170 | return ( | |
| 246 | 171 | <NavLink | |
| 247 | 172 | to={to} | |
| 248 | − | end={end} | |
| 173 | + | title={about} | |
| 249 | 174 | prefetch="intent" | |
| 250 | − | className={({ isActive, isPending }) => { | |
| 251 | − | const current = | |
| 252 | − | isActive || [also ?? []].flat().some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/")); | |
| 253 | − | return `relative flex h-8 items-center gap-2 rounded-md pr-2 pl-[2.375rem] text-[0.8125rem] transition-colors ${ | |
| 254 | − | current | |
| 255 | − | ? "bg-raised font-medium text-fg before:absolute before:top-1.5 before:bottom-1.5 before:left-[1.1875rem] before:z-10 before:w-px before:bg-accent" | |
| 256 | − | : isPending | |
| 257 | − | ? "bg-raised/60 text-fg" | |
| 258 | − | : "text-muted hover:bg-raised/60 hover:text-fg" | |
| 175 | + | className={({ isActive }) => { | |
| 176 | + | const current = isActive || (also ?? []).some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/")); | |
| 177 | + | return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${ | |
| 178 | + | current ? "bg-raised font-medium text-fg" : "text-faint hover:bg-raised/60 hover:text-muted" | |
| 259 | 179 | }`; | |
| 260 | 180 | }} | |
| 261 | 181 | > | |
| 182 | + | <span className="shrink-0 opacity-80">{icon}</span> | |
| 262 | 183 | <span className="min-w-0 grow truncate">{children}</span> | |
| 263 | − | {count != null && count > 0 && ( | |
| 264 | − | <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">{count}</span> | |
| 265 | − | )} | |
| 266 | − | </NavLink> | |
| 267 | − | ); | |
| 268 | − | } | |
| 269 | − | ||
| 270 | − | /** | |
| 271 | − | * A page a section will have: a link to a page saying what it will be, | |
| 272 | − | * so people can see where g1t is going, and read about it. | |
| 273 | − | */ | |
| 274 | − | function SidebarSubSoon({ to, about, children }: { to: string; about: string; children: ReactNode }) { | |
| 275 | − | return ( | |
| 276 | − | <NavLink | |
| 277 | − | to={to} | |
| 278 | − | title={about} | |
| 279 | − | prefetch="intent" | |
| 280 | − | className={({ isActive }) => | |
| 281 | − | `relative flex h-8 items-center gap-2 rounded-md pr-2 pl-[2.375rem] text-[0.8125rem] transition-colors ${ | |
| 282 | − | isActive | |
| 283 | − | ? "bg-raised text-fg before:absolute before:top-1.5 before:bottom-1.5 before:left-[1.1875rem] before:z-10 before:w-px before:bg-accent" | |
| 284 | − | : "text-faint hover:bg-raised/60 hover:text-muted" | |
| 285 | − | }` | |
| 286 | − | } | |
| 287 | − | > | |
| 288 | − | <span className="grow truncate">{children}</span> | |
| 289 | 184 | <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line"> | |
| 290 | 185 | Soon | |
| 291 | 186 | </span> | |
| 293 | 188 | ); | |
| 294 | 189 | } | |
| 295 | 190 | ||
| 296 | − | /** A section's Soon pages, from the roadmap. */ | |
| 297 | − | function SoonItems({ base, section }: { base: string; section: RoadmapItem["section"] }) { | |
| 298 | − | return ( | |
| 299 | − | <> | |
| 300 | − | {roadmapIn(section).map((item) => ( | |
| 301 | − | <SidebarSubSoon key={item.key} to={`${base}/soon/${item.key}`} about={item.summary}> | |
| 302 | − | {item.title} | |
| 303 | − | </SidebarSubSoon> | |
| 304 | − | ))} | |
| 305 | − | </> | |
| 306 | − | ); | |
| 307 | − | } | |
| 191 | + | /** Icons for what spans projects. */ | |
| 192 | + | const WORKSPACE_ICONS: Record<string, ReactNode> = { | |
| 193 | + | board: <KanbanSquare size={15} />, | |
| 194 | + | roadmap: <GanttChart size={15} />, | |
| 195 | + | packages: <Package size={15} />, | |
| 196 | + | fleet: <Bot size={15} />, | |
| 197 | + | }; | |
| 308 | 198 | ||
| 309 | − | /** A section's own pages and its Soon pages, for opening it when current. */ | |
| 310 | − | function sectionPaths(base: string, section: RoadmapItem["section"], own: string[]): string[] { | |
| 311 | − | return [...own, ...roadmapIn(section).map((item) => `${base}/soon/${item.key}`)]; | |
| 199 | + | /** The Soon pages of a section, as paths, so its link is current on them. */ | |
| 200 | + | function soonPaths(base: string, section: RoadmapItem["section"]): string[] { | |
| 201 | + | return roadmapIn(section).map((item) => `${base}/soon/${item.key}`); | |
| 312 | 202 | } | |
| 313 | 203 | ||
| 314 | 204 | function SidebarGroup({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) { | |
| 555 | 445 | <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" /> | |
| 556 | 446 | Mission control | |
| 557 | 447 | </Link> | |
| 558 | − | <Link | |
| 448 | + | <NavLink | |
| 559 | 449 | to={base} | |
| 560 | − | className="mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-raised/60" | |
| 450 | + | end | |
| 451 | + | title="Overview" | |
| 452 | + | className={({ isActive }) => | |
| 453 | + | `mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors ${isActive ? "bg-raised" : "hover:bg-raised/60"}` | |
| 454 | + | } | |
| 561 | 455 | > | |
| 562 | 456 | <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line"> | |
| 563 | 457 | {isPrivate ? <Lock size={13} /> : <Box size={13} />} | |
| 566 | 460 | <span className="text-faint">{repo.namespace}/</span> | |
| 567 | 461 | <span className="font-semibold text-fg">{repo.name}</span> | |
| 568 | 462 | </span> | |
| 569 | − | </Link> | |
| 463 | + | </NavLink> | |
| 464 | + | {/* A project's pages, flat and in the order people use them. A page | |
| 465 | + | with more than one view shows them as tabs across its top. */} | |
| 570 | 466 | <div className="mt-3 space-y-px"> | |
| 571 | − | <SidebarLink to={base} end icon={<LayoutGrid size={15} />}> | |
| 572 | − | Overview | |
| 467 | + | <SidebarLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`, ...soonPaths(base, "Code")]} icon={<Code2 size={15} />}> | |
| 468 | + | Code | |
| 469 | + | </SidebarLink> | |
| 470 | + | <SidebarLink to={`${base}/issues`} also={[`${base}/plans`, ...soonPaths(base, "Issues")]} icon={<CircleDot size={15} />} count={repo.issues}> | |
| 471 | + | Issues | |
| 472 | + | </SidebarLink> | |
| 473 | + | <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/queue`]} icon={<GitPullRequest size={15} />} count={repo.pulls}> | |
| 474 | + | Pull requests | |
| 475 | + | </SidebarLink> | |
| 476 | + | <SidebarSoonLink to={`${base}/soon/agents`} also={soonPaths(base, "Agents")} icon={<Bot size={15} />} about="Every agent at work on this project, its sessions, playbooks and memory."> | |
| 477 | + | Agents | |
| 478 | + | </SidebarSoonLink> | |
| 479 | + | <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}> | |
| 480 | + | Workflows | |
| 573 | 481 | </SidebarLink> | |
| 574 | − | ||
| 575 | − | <SidebarSection | |
| 576 | − | title="Work" | |
| 577 | − | icon={<Kanban size={15} />} | |
| 578 | − | paths={sectionPaths(base, "Work", [`${base}/issues`, `${base}/pulls`, `${base}/pull`, `${base}/queue`, `${base}/plans`])} | |
| 579 | − | > | |
| 580 | − | <SidebarSubLink to={`${base}/issues`} count={repo.issues}> | |
| 581 | − | Issues | |
| 582 | − | </SidebarSubLink> | |
| 583 | − | <SidebarSubLink to={`${base}/pulls`} also={`${base}/pull`} count={repo.pulls}> | |
| 584 | − | Pull requests | |
| 585 | − | </SidebarSubLink> | |
| 586 | − | <SidebarSubLink to={`${base}/queue`}>Merge queue</SidebarSubLink> | |
| 587 | − | {repo.member && <SidebarSubLink to={`${base}/plans`}>Outcomes</SidebarSubLink>} | |
| 588 | − | <SoonItems base={base} section="Work" /> | |
| 589 | − | </SidebarSection> | |
| 590 | − | ||
| 591 | − | <SidebarSection | |
| 592 | − | title="Code" | |
| 593 | − | icon={<Code2 size={15} />} | |
| 594 | − | paths={sectionPaths(base, "Code", [`${base}/code`, `${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`])} | |
| 595 | − | > | |
| 596 | − | <SidebarSubLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`]}> | |
| 597 | − | Files | |
| 598 | − | </SidebarSubLink> | |
| 599 | − | <SidebarSubLink to={`${base}/commits`} also={`${base}/commit`}> | |
| 600 | − | Commits | |
| 601 | − | </SidebarSubLink> | |
| 602 | − | <SoonItems base={base} section="Code" /> | |
| 603 | − | </SidebarSection> | |
| 604 | − | ||
| 605 | − | <SidebarSection title="Agents" icon={<Bot size={15} />} paths={sectionPaths(base, "Agents", [])} soon> | |
| 606 | − | <SoonItems base={base} section="Agents" /> | |
| 607 | − | </SidebarSection> | |
| 608 | − | ||
| 609 | − | <SidebarSection | |
| 610 | − | title="Ship" | |
| 611 | − | icon={<Rocket size={15} />} | |
| 612 | − | paths={sectionPaths(base, "Ship", [`${base}/actions`, ...(repo.member ? [`${base}/deployments`] : [])])} | |
| 613 | − | > | |
| 614 | − | <SidebarSubLink to={`${base}/actions`}>Workflows</SidebarSubLink> | |
| 615 | − | {repo.member && <SidebarSubLink to={`${base}/deployments`}>Deployments</SidebarSubLink>} | |
| 616 | − | <SoonItems base={base} section="Ship" /> | |
| 617 | − | </SidebarSection> | |
| 618 | − | ||
| 619 | − | <SidebarSection title="Security" icon={<ShieldCheck size={15} />} paths={sectionPaths(base, "Security", [])} soon> | |
| 620 | − | <SoonItems base={base} section="Security" /> | |
| 621 | − | </SidebarSection> | |
| 622 | − | ||
| 623 | − | <SidebarSection title="Observe" icon={<Activity size={15} />} paths={sectionPaths(base, "Observe", [])} soon> | |
| 624 | − | <SoonItems base={base} section="Observe" /> | |
| 625 | − | </SidebarSection> | |
| 626 | − | ||
| 627 | − | <SidebarSection title="Insights" icon={<BarChart3 size={15} />} paths={sectionPaths(base, "Insights", [])} soon> | |
| 628 | − | <SoonItems base={base} section="Insights" /> | |
| 629 | − | </SidebarSection> | |
| 630 | − | ||
| 482 | + | {repo.member ? ( | |
| 483 | + | <SidebarLink to={`${base}/deployments`} also={soonPaths(base, "Deployments")} icon={<Rocket size={15} />}> | |
| 484 | + | Deployments | |
| 485 | + | </SidebarLink> | |
| 486 | + | ) : null} | |
| 487 | + | <SidebarSoonLink to={`${base}/soon/security`} also={soonPaths(base, "Security")} icon={<ShieldCheck size={15} />} about="Findings, secret scanning, dependency updates and code scanning, each fixed by an agent."> | |
| 488 | + | Security | |
| 489 | + | </SidebarSoonLink> | |
| 490 | + | <SidebarSoonLink to={`${base}/soon/delivery`} also={soonPaths(base, "Insights")} icon={<BarChart3 size={15} />} about="Delivery metrics, costs and the work agents do."> | |
| 491 | + | Insights | |
| 492 | + | </SidebarSoonLink> | |
| 631 | 493 | {repo.member && ( | |
| 632 | − | <SidebarSection title="Settings" icon={<Settings size={15} />} paths={[`${base}/settings`]}> | |
| 633 | − | <SidebarSubLink to={`${base}/settings`} end> | |
| 634 | − | General | |
| 635 | − | </SidebarSubLink> | |
| 636 | − | <SidebarSubLink to={`${base}/settings/repository`}>Repository</SidebarSubLink> | |
| 637 | − | <SidebarSubLink to={`${base}/settings/deployments`}>Deployments</SidebarSubLink> | |
| 638 | − | <SidebarSubLink to={`${base}/settings/dependencies`}>Dependencies</SidebarSubLink> | |
| 639 | − | <SidebarSubLink to={`${base}/settings/secrets`}>Secrets and variables</SidebarSubLink> | |
| 640 | − | <SidebarSubLink to={`${base}/settings/webhooks`}>Webhooks</SidebarSubLink> | |
| 641 | − | </SidebarSection> | |
| 494 | + | <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />}> | |
| 495 | + | Settings | |
| 496 | + | </SidebarLink> | |
| 642 | 497 | )} | |
| 643 | 498 | </div> | |
| 644 | 499 | </nav> | |
| 802 | 657 | ); | |
| 803 | 658 | })} | |
| 804 | 659 | </SidebarGroup> | |
| 660 | + | {ws && ( | |
| 661 | + | <SidebarGroup title="Across projects"> | |
| 662 | + | {roadmapIn("Workspace").map((item) => ( | |
| 663 | + | <SidebarSoonLink | |
| 664 | + | key={item.key} | |
| 665 | + | to={`/${ws.slug}/-/soon/${item.key}`} | |
| 666 | + | icon={WORKSPACE_ICONS[item.key] ?? <Sparkles size={15} />} | |
| 667 | + | about={item.summary} | |
| 668 | + | > | |
| 669 | + | {item.title === "Board" ? "Boards" : item.title} | |
| 670 | + | </SidebarSoonLink> | |
| 671 | + | ))} | |
| 672 | + | </SidebarGroup> | |
| 673 | + | )} | |
| 805 | 674 | </nav> | |
| 806 | 675 | </div> | |
| 807 | 676 | <div className={`${LAYER} ${away ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}> |
| 11 | 11 | /** In the address: `/<owner>/<project>/soon/<key>`. */ | |
| 12 | 12 | key: string; | |
| 13 | 13 | title: string; | |
| 14 | − | /** The menu section it sits in. */ | |
| 15 | − | section: "Work" | "Code" | "Agents" | "Ship" | "Security" | "Observe" | "Insights"; | |
| 14 | + | /** | |
| 15 | + | * Where it lives: a project page whose tabs it joins, or the workspace, | |
| 16 | + | * for what spans projects (boards, the roadmap, packages, the fleet). | |
| 17 | + | */ | |
| 18 | + | section: "Code" | "Issues" | "Agents" | "Deployments" | "Security" | "Insights" | "Workspace"; | |
| 16 | 19 | /** One line, for the menu's tooltip and the page's lead. */ | |
| 17 | 20 | summary: string; | |
| 18 | 21 | /** Why it matters, in two or three sentences. */ | |
| 28 | 31 | { | |
| 29 | 32 | key: "board", | |
| 30 | 33 | title: "Board", | |
| 31 | − | section: "Work", | |
| 34 | + | section: "Workspace", | |
| 32 | 35 | summary: "Issues and pull requests as a board, a table or a roadmap, with fields of your own.", | |
| 33 | 36 | why: "Like GitHub Projects: one view of everything in flight, arranged the way your team thinks about it. Agents move the cards as they work, so the board is never out of date.", | |
| 34 | 37 | plans: [ | |
| 36 | 39 | "Custom fields: status, priority, size, iteration, dates, anything", | |
| 37 | 40 | "Group and filter by owner, outcome, label, agent or state", | |
| 38 | 41 | "Cards move by themselves as agents open, revise and land changes", | |
| 39 | − | "Boards across projects, for a workspace's whole portfolio", | |
| 42 | + | "Across projects: one board can hold work from any of the workspace's projects", | |
| 43 | + | "Attach a board to a project, so it shows on that project too", | |
| 40 | 44 | ], | |
| 41 | − | today: { label: "Issues", path: "issues" }, | |
| 42 | 45 | }, | |
| 43 | 46 | { | |
| 44 | 47 | key: "roadmap", | |
| 45 | 48 | title: "Roadmap", | |
| 46 | − | section: "Work", | |
| 49 | + | section: "Workspace", | |
| 47 | 50 | summary: "Outcomes on a timeline, with their dependencies across projects.", | |
| 48 | 51 | why: "An outcome is what should be true when the work is done. On a roadmap you see when each is expected, what it waits on in other projects, and how much has landed.", | |
| 49 | 52 | plans: [ | |
| 57 | 60 | { | |
| 58 | 61 | key: "milestones", | |
| 59 | 62 | title: "Milestones", | |
| 60 | − | section: "Work", | |
| 63 | + | section: "Issues", | |
| 61 | 64 | summary: "Dates to land outcomes by, with what is left and what is at risk.", | |
| 62 | 65 | why: "A milestone groups the issues that must land together, by a date. g1t shows what remains and which agent or person holds each part.", | |
| 63 | 66 | plans: [ | |
| 168 | 171 | plans: ["Facts agents learned, with where they learned them", "Edit or remove anything", "Shared across every agent on the project"], | |
| 169 | 172 | }, | |
| 170 | 173 | ||
| 171 | − | // --- Ship --------------------------------------------------------------- | |
| 172 | 174 | { | |
| 175 | + | key: "fleet", | |
| 176 | + | title: "Agent fleet", | |
| 177 | + | section: "Workspace", | |
| 178 | + | summary: "Every agent at work across the workspace, and what each is costing.", | |
| 179 | + | why: "Agents work on many projects at once. The fleet shows them all: what each holds, how far along it is, and where people are needed.", | |
| 180 | + | plans: [ | |
| 181 | + | "Every running agent across projects, live", | |
| 182 | + | "Queue of work waiting for an agent, by priority", | |
| 183 | + | "Spend by agent, project and kind of work", | |
| 184 | + | "Pause or redirect agents across the workspace", | |
| 185 | + | ], | |
| 186 | + | }, | |
| 187 | + | ||
| 188 | + | // --- Deployments ---------------------------------------------------------- | |
| 189 | + | { | |
| 173 | 190 | key: "environments", | |
| 174 | 191 | title: "Environments", | |
| 175 | − | section: "Ship", | |
| 192 | + | section: "Deployments", | |
| 176 | 193 | summary: "Staging, production and others, with approvers and branch rules.", | |
| 177 | 194 | why: "Production and previews exist today. Environments add the rest: staging, QA, per-customer, each with its own variables, approvers and rules for what may deploy there.", | |
| 178 | 195 | plans: [ | |
| 186 | 203 | { | |
| 187 | 204 | key: "releases", | |
| 188 | 205 | title: "Releases", | |
| 189 | − | section: "Ship", | |
| 206 | + | section: "Deployments", | |
| 190 | 207 | summary: "Tagged releases with notes written from what landed.", | |
| 191 | 208 | why: "Release notes from the pull requests and sessions that made the release: what changed, why, and who or what changed it.", | |
| 192 | 209 | plans: ["Notes drafted from merged work", "Assets and checksums attached", "Published to the project's page and a feed"], | |
| 195 | 212 | { | |
| 196 | 213 | key: "packages", | |
| 197 | 214 | title: "Packages", | |
| 198 | − | section: "Ship", | |
| 199 | − | summary: "npm, container and other packages published from the project.", | |
| 215 | + | section: "Workspace", | |
| 216 | + | summary: "The workspace's package registry: npm, containers and more.", | |
| 200 | 217 | why: "Publish packages from workflows to g1t's registry, with the same access as the code.", | |
| 201 | − | plans: ["npm, OCI containers, Cargo, PyPI and Go modules", "Published from workflows", "Private packages for the workspace"], | |
| 202 | − | today: { label: "Workflows", path: "actions" }, | |
| 218 | + | plans: ["npm, OCI containers, Cargo, PyPI and Go modules", "Published from any project's workflows", "Private packages for the workspace"], | |
| 203 | 219 | }, | |
| 204 | 220 | { | |
| 205 | 221 | key: "flags", | |
| 206 | 222 | title: "Feature flags", | |
| 207 | − | section: "Ship", | |
| 223 | + | section: "Deployments", | |
| 208 | 224 | summary: "Turn features on per environment or per user, without a deploy.", | |
| 209 | 225 | why: "Ship code dark and turn it on when ready, for some users first, at the edge, with no deploy.", | |
| 210 | 226 | plans: ["Flags read at the edge by deployed apps", "Rollouts by percentage, user or environment", "Flags cleaned up by agents when fully on"], | |
| 258 | 274 | { | |
| 259 | 275 | key: "logs", | |
| 260 | 276 | title: "Logs and metrics", | |
| 261 | − | section: "Observe", | |
| 277 | + | section: "Deployments", | |
| 262 | 278 | summary: "Requests, errors and CPU time of each deployment, and its logs.", | |
| 263 | 279 | why: "Every deployed app's logs and numbers, by deployment, so a regression points at the change that caused it.", | |
| 264 | 280 | plans: ["Live and searchable logs", "Requests, errors, latency and CPU by deployment", "Compare a preview with production"], | |
| 267 | 283 | { | |
| 268 | 284 | key: "errors", | |
| 269 | 285 | title: "Errors and incidents", | |
| 270 | − | section: "Observe", | |
| 286 | + | section: "Deployments", | |
| 271 | 287 | summary: "Errors from the running app, each becoming an issue an agent can take.", | |
| 272 | 288 | why: "An error in production should become a fix, not a dashboard. Each new error is grouped, explained, and turned into an issue with the context an agent needs.", | |
| 273 | 289 | plans: ["Errors grouped with stack and request", "One click to an issue for an agent", "Incidents with a timeline and who was told"], | |
| 276 | 292 | { | |
| 277 | 293 | key: "uptime", | |
| 278 | 294 | title: "Uptime", | |
| 279 | − | section: "Observe", | |
| 295 | + | section: "Deployments", | |
| 280 | 296 | summary: "Checks that the app answers, from around the world.", | |
| 281 | 297 | why: "Know the app is down before your users tell you, and who was told.", | |
| 282 | 298 | plans: ["Checks from many places", "Alerts by email and webhook", "A public status page"], | |
| 284 | 300 | { | |
| 285 | 301 | key: "analytics", | |
| 286 | 302 | title: "Web analytics", | |
| 287 | − | section: "Observe", | |
| 303 | + | section: "Deployments", | |
| 288 | 304 | summary: "Who visits the deployed apps, without cookies.", | |
| 289 | 305 | why: "Visits, pages and referrers for each app, private by design, from Cloudflare's own analytics.", | |
| 290 | 306 | plans: ["Visits, pages, referrers and countries", "No cookies, no personal data", "Per deployment and per preview"], |
| 27 | 27 | route("-/webhooks", "routes/workspace/webhooks.tsx"), | |
| 28 | 28 | route("-/secrets", "routes/workspace/secrets.tsx"), | |
| 29 | 29 | route("-/settings", "routes/workspace/settings.tsx"), | |
| 30 | + | // What the workspace will have across its projects. | |
| 31 | + | route("-/soon/:feature", "routes/workspace/soon.tsx"), | |
| 30 | 32 | ]), | |
| 31 | 33 | // Why a line is the way it is, fetched by the blame view. | |
| 32 | 34 | route(":owner/:repo/why/:hash", "routes/repo/why.ts"), |
| 1 | 1 | import { Box, CircleDot, Code2, GitPullRequest, History, LayoutGrid, ListTree, Lock, Rocket, Settings } from "lucide-react"; | |
| 2 | − | import { Link, Outlet, data, useRouteLoaderData } from "react-router"; | |
| 2 | + | import { Link, NavLink, Outlet, data, useLocation, useRouteLoaderData } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Project } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/layout"; | |
| 7 | 7 | import { page } from "../../lib/meta"; | |
| 8 | + | import { type Tab as PageTab, tabsFor } from "../../lib/project-nav"; | |
| 8 | 9 | import { Pill, TabLink as Tab } from "../../components/ui"; | |
| 9 | 10 | import { projects, repos, work } from "../../lib/services.server"; | |
| 10 | 11 | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 72 | 73 | ); | |
| 73 | 74 | } | |
| 74 | 75 | ||
| 76 | + | /** | |
| 77 | + | * The views of the page the project is on, as tabs across its top: Files, | |
| 78 | + | * Commits and what is coming under Code, and so on. Soon tabs open their | |
| 79 | + | * roadmap page, under the same tabs. | |
| 80 | + | */ | |
| 81 | + | function PageTabs({ base, tabs }: { base: string; tabs: PageTab[] }) { | |
| 82 | + | const { pathname } = useLocation(); | |
| 83 | + | const rest = pathname.slice(base.length + 1); | |
| 84 | + | const current = (tab: PageTab) => | |
| 85 | + | [tab.path, ...(tab.also ?? [])].some((path) => rest === path || rest.startsWith(`${path}/`)); | |
| 86 | + | return ( | |
| 87 | + | <nav aria-label="Views" className="-mb-px flex gap-1 overflow-x-auto"> | |
| 88 | + | {tabs.map((tab) => ( | |
| 89 | + | <NavLink | |
| 90 | + | key={tab.path} | |
| 91 | + | to={`${base}/${tab.path}`} | |
| 92 | + | title={tab.about} | |
| 93 | + | prefetch="intent" | |
| 94 | + | className={`flex shrink-0 items-center gap-1.5 border-b-2 px-3 pt-1 pb-2.5 text-sm transition-colors ${ | |
| 95 | + | current(tab) | |
| 96 | + | ? "border-accent font-medium text-fg" | |
| 97 | + | : tab.soon | |
| 98 | + | ? "border-transparent text-faint hover:text-muted" | |
| 99 | + | : "border-transparent text-muted hover:text-fg" | |
| 100 | + | }`} | |
| 101 | + | > | |
| 102 | + | {tab.label} | |
| 103 | + | {tab.soon && ( | |
| 104 | + | <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line"> | |
| 105 | + | Soon | |
| 106 | + | </span> | |
| 107 | + | )} | |
| 108 | + | </NavLink> | |
| 109 | + | ))} | |
| 110 | + | </nav> | |
| 111 | + | ); | |
| 112 | + | } | |
| 113 | + | ||
| 75 | 114 | export default function ProjectLayout({ loaderData }: Route.ComponentProps) { | |
| 76 | 115 | const { repo, project, open, member } = loaderData; | |
| 77 | 116 | const base = `/${repo.namespace}/${repo.name}`; | |
| 78 | 117 | const signedIn = useRouteLoaderData("root")?.user != null; | |
| 79 | 118 | const description = project?.description ?? repo.description; | |
| 119 | + | const { pathname } = useLocation(); | |
| 120 | + | const tabs = tabsFor(pathname.slice(base.length + 1), member); | |
| 80 | 121 | if (signedIn) { | |
| 81 | 122 | return ( | |
| 82 | 123 | <> | |
| 83 | 124 | <div className="border-b border-line"> | |
| 84 | − | <div className="mx-auto max-w-6xl px-4 py-3 sm:px-6"> | |
| 125 | + | <div className={`mx-auto max-w-6xl px-4 sm:px-6 ${tabs ? "pt-3" : "py-3"}`}> | |
| 85 | 126 | <Header | |
| 86 | 127 | project={project} | |
| 87 | 128 | isPrivate={repo.isPrivate} | |
| 89 | 130 | name={repo.name} | |
| 90 | 131 | description={description} | |
| 91 | 132 | /> | |
| 133 | + | {tabs && ( | |
| 134 | + | <div className="mt-3"> | |
| 135 | + | <PageTabs base={base} tabs={tabs} /> | |
| 136 | + | </div> | |
| 137 | + | )} | |
| 92 | 138 | </div> | |
| 93 | 139 | </div> | |
| 94 | 140 | <div className="mx-auto max-w-6xl px-4 py-6 sm:px-6"> |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/soon"; | |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| 6 | − | import { ROADMAP, roadmapIn, roadmapItem } from "../../lib/roadmap"; | |
| 6 | + | import { ROADMAP, type RoadmapItem, roadmapIn, roadmapItem } from "../../lib/roadmap"; | |
| 7 | 7 | ||
| 8 | 8 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 9 | 9 | const item = roadmapItem(params.feature); | |
| 25 | 25 | * to use until then. Reached from the project menu's Soon items. | |
| 26 | 26 | */ | |
| 27 | 27 | export default function Soon({ loaderData, params }: Route.ComponentProps) { | |
| 28 | − | const { item } = loaderData; | |
| 29 | − | const base = `/${params.owner}/${params.repo}`; | |
| 28 | + | return <SoonView item={loaderData.item} base={`/${params.owner}/${params.repo}`} />; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /** | |
| 32 | + | * A Soon page, for a project's (under `base`, `/<owner>/<project>`) or the | |
| 33 | + | * workspace's (`/<owner>/-`). | |
| 34 | + | */ | |
| 35 | + | export function SoonView({ item, base }: { item: RoadmapItem; base: string }) { | |
| 30 | 36 | const siblings = roadmapIn(item.section).filter((other) => other.key !== item.key); | |
| 37 | + | const inWorkspace = item.section === "Workspace"; | |
| 31 | 38 | return ( | |
| 32 | 39 | <div className="mx-auto max-w-3xl"> | |
| 33 | 40 | <p className="flex items-center gap-2 text-sm text-muted"> | |
| 34 | − | <span>{item.section}</span> | |
| 41 | + | <span>{inWorkspace ? "Across projects" : item.section}</span> | |
| 35 | 42 | <span className="text-line-strong">/</span> | |
| 36 | 43 | <span className="text-fg">{item.title}</span> | |
| 37 | 44 | </p> | |
| 72 | 79 | ||
| 73 | 80 | {siblings.length > 0 && ( | |
| 74 | 81 | <section className="mt-10"> | |
| 75 | − | <h2 className="text-sm font-medium text-muted">Also coming to {item.section}</h2> | |
| 82 | + | <h2 className="text-sm font-medium text-muted"> | |
| 83 | + | Also coming {inWorkspace ? "across projects" : `to ${item.section}`} | |
| 84 | + | </h2> | |
| 76 | 85 | <ul className="mt-3 grid gap-3 sm:grid-cols-2"> | |
| 77 | 86 | {siblings.map((other) => ( | |
| 78 | 87 | <li key={other.key}> |
| 1 | − | import { CreditCard, Rocket } from "lucide-react"; | |
| 1 | + | import { ArrowUpRight, CreditCard, FileText, Receipt, Rocket } from "lucide-react"; | |
| 2 | 2 | import { Form, Link, data, redirect, useNavigation } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import { | |
| 145 | 145 | </p> | |
| 146 | 146 | </div> | |
| 147 | 147 | )} | |
| 148 | + | {account.status.enabled && ( | |
| 149 | + | <section className="mb-8 flex flex-wrap items-center gap-4 rounded-xl border border-line bg-surface p-5"> | |
| 150 | + | <span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-accent/10 text-accent ring-1 ring-accent/30"> | |
| 151 | + | <Receipt size={18} /> | |
| 152 | + | </span> | |
| 153 | + | <div className="min-w-0 grow"> | |
| 154 | + | <h2 className="font-medium">Billing on Stripe</h2> | |
| 155 | + | <p className="mt-0.5 text-sm text-muted"> | |
| 156 | + | {account.card | |
| 157 | + | ? `${account.card.brand[0].toUpperCase()}${account.card.brand.slice(1)} ending ${account.card.last4}. ` | |
| 158 | + | : "No card yet. "} | |
| 159 | + | Your card, invoices and receipts, and the billing email, address and tax ID. | |
| 160 | + | </p> | |
| 161 | + | </div> | |
| 162 | + | {role === "owner" ? ( | |
| 163 | + | <Form method="post"> | |
| 164 | + | <Button variant="accent" type="submit" name="intent" value="portal" disabled={paying}> | |
| 165 | + | Open Stripe billing | |
| 166 | + | <ArrowUpRight size={14} /> | |
| 167 | + | </Button> | |
| 168 | + | </Form> | |
| 169 | + | ) : ( | |
| 170 | + | <p className="text-xs text-faint">An owner manages billing.</p> | |
| 171 | + | )} | |
| 172 | + | </section> | |
| 173 | + | )} | |
| 174 | + | ||
| 148 | 175 | {limit && account.status.enabled && ( | |
| 149 | 176 | <LimitCard limit={limit} owner={role === "owner"} busy={paying} error={actionData?.error} /> | |
| 150 | 177 | )} | |
| 151 | 178 | ||
| 152 | − | {account.status.enabled && limit?.trust !== "internal" && ( | |
| 179 | + | {account.status.enabled && ( | |
| 153 | 180 | <section className="mb-10 rounded-xl border border-line bg-surface p-5"> | |
| 154 | 181 | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 155 | 182 | <div> | |
| 333 | 360 | <h3 className="font-medium">How it is charged</h3> | |
| 334 | 361 | <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted"> | |
| 335 | 362 | <li> | |
| 336 | − | Each run is charged when it finishes, to the workspace that owns the | |
| 337 | − | repository, whoever assigned the issue. | |
| 338 | − | </li> | |
| 339 | − | <li> | |
| 340 | − | A change, a review, a revision and a catch-up are each a run. The | |
| 341 | − | statement links each to its pull request. | |
| 363 | + | Usage is charged after it runs, to the workspace that owns the repository: agents at what the model cost | |
| 364 | + | plus {account.marginPercent}%, and sandbox time, builds and apps at what they cost g1t plus a set markup. | |
| 342 | 365 | </li> | |
| 343 | 366 | <li> | |
| 344 | − | Every pull request's session ends with what that run cost before | |
| 345 | − | the margin. | |
| 367 | + | Each month closes with an itemised invoice, charged to the card on file. Near your limit, g1t sends one | |
| 368 | + | sooner, so work keeps going. | |
| 346 | 369 | </li> | |
| 347 | 370 | <li> | |
| 348 | 371 | With your own model provider, connected under{" "} | |
| 349 | 372 | <Link to={`/${slug}/-/integrations`} className="text-fg hover:underline"> | |
| 350 | 373 | Integrations | |
| 351 | 374 | </Link> | |
| 352 | − | , the provider bills you for the model and each run here is a flat{" "} | |
| 353 | − | {dollars(account.orchestrationFeeMicros)} | |
| 354 | − | {status.free && " once pricing starts; nothing while g1t is being built out"}. | |
| 355 | − | </li> | |
| 356 | − | <li> | |
| 357 | − | Only members of <span className="font-mono text-fg">{slug}</span> can | |
| 358 | − | put agents to work on its repositories. | |
| 375 | + | , the provider bills you for the model and each run here is {dollars(account.orchestrationFeeMicros)}, plus | |
| 376 | + | its sandbox time. | |
| 359 | 377 | </li> | |
| 378 | + | <li>No seats: add as many people and agents as you like.</li> | |
| 360 | 379 | </ul> | |
| 380 | + | <div className="mt-4 space-y-1.5 border-t border-line pt-4 text-sm"> | |
| 381 | + | <Link to={`/${slug}/-/usage`} className="flex items-center gap-2 text-muted hover:text-fg"> | |
| 382 | + | <FileText size={14} /> Usage, run by run | |
| 383 | + | </Link> | |
| 384 | + | <a href="https://g1t.sh/pricing" className="flex items-center gap-2 text-muted hover:text-fg"> | |
| 385 | + | <CreditCard size={14} /> Today's prices | |
| 386 | + | </a> | |
| 387 | + | <a | |
| 388 | + | href="https://docs.g1t.sh/guides/usage-and-billing/" | |
| 389 | + | className="flex items-center gap-2 text-muted hover:text-fg" | |
| 390 | + | > | |
| 391 | + | <ArrowUpRight size={14} /> How usage and billing work | |
| 392 | + | </a> | |
| 393 | + | </div> | |
| 361 | 394 | </section> | |
| 362 | 395 | </aside> | |
| 363 | 396 | </div> |
| 1 | + | import { data } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { Route } from "./+types/soon"; | |
| 4 | + | import { page } from "../../lib/meta"; | |
| 5 | + | import { roadmapItem } from "../../lib/roadmap"; | |
| 6 | + | import { SoonView } from "../repo/soon"; | |
| 7 | + | ||
| 8 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 9 | + | const item = roadmapItem(params.feature); | |
| 10 | + | return page(args, { | |
| 11 | + | title: `${item?.title ?? "Soon"} · ${params.owner} · g1t`, | |
| 12 | + | description: item ? `Soon on g1t: ${item.summary}` : null, | |
| 13 | + | version: item ? [item.title, item.summary] : undefined, | |
| 14 | + | }); | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | export function loader({ params }: Route.LoaderArgs) { | |
| 18 | + | const item = roadmapItem(params.feature); | |
| 19 | + | // Only what spans projects lives under the workspace. | |
| 20 | + | if (!item || item.section !== "Workspace") throw data(null, { status: 404 }); | |
| 21 | + | return { item }; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /** What a workspace will have across its projects: boards, the roadmap, packages, the fleet. */ | |
| 25 | + | export default function WorkspaceSoon({ loaderData, params }: Route.ComponentProps) { | |
| 26 | + | return ( | |
| 27 | + | <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8"> | |
| 28 | + | <SoonView item={loaderData.item} base={`/${params.owner}/-`} /> | |
| 29 | + | </div> | |
| 30 | + | ); | |
| 31 | + | } |