Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
1 commit
8 files+346−2530/8 viewed
| 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 | + | } |