| 1 | import { Package, Plus } from "lucide-react"; |
| 2 | import { Outlet, data, redirect, useLocation, useNavigation } from "react-router"; |
| 3 | |
| 4 | import type { Route } from "./+types/layout"; |
| 5 | import { page } from "../../lib/meta"; |
| 6 | import { Avatar, ButtonLink, Pill } from "../../components/ui"; |
| 7 | import { WelcomeBanner } from "../../components/welcome"; |
| 8 | import { clearWelcome, welcomes } from "../../lib/invites"; |
| 9 | import { notFound } from "../../lib/not-found.server"; |
| 10 | import { redirectIfRenamed } from "../../lib/renamed.server"; |
| 11 | import { rememberWorkspace } from "../../lib/workspace-choice"; |
| 12 | import { workspacePage, workspaceRedirect } from "../../lib/workspace-nav"; |
| 13 | import { WorkspacePageSkeleton } from "../../components/workspace-skeletons"; |
| 14 | import { identity } from "../../lib/services.server"; |
| 15 | import { getViewer, roleIn } from "../../lib/session.server"; |
| 16 | |
| 17 | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { |
| 18 | return page(args, { title: `${loaderData?.workspace.name ?? params.owner} · g1t` }); |
| 19 | } |
| 20 | |
| 21 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 22 | const url = new URL(request.url); |
| 23 | // `?tab=projects` and the like, from the tabs this page once had: that |
| 24 | // page's own address. |
| 25 | const moved = url.search ? workspaceRedirect(url.pathname, url.search) : null; |
| 26 | if (moved) throw redirect(moved); |
| 27 | const viewer = getViewer(context); |
| 28 | const workspace = await identity.getWorkspace(params.owner); |
| 29 | if (!workspace) { |
| 30 | // A workspace's old name, after a rename: its pages are at the new one. |
| 31 | await redirectIfRenamed(request, params.owner); |
| 32 | throw notFound("workspace"); |
| 33 | } |
| 34 | const role = roleIn(viewer, workspace.slug); |
| 35 | // Opening one of your workspaces makes it the one you are in. |
| 36 | if (!role) return { workspace, role, welcome: false }; |
| 37 | const secure = new URL(request.url).protocol === "https:"; |
| 38 | const headers = new Headers({ "Set-Cookie": rememberWorkspace(workspace.slug, secure) }); |
| 39 | // Just joined with an invite: welcomed once (routes/invite.tsx). |
| 40 | const welcome = welcomes(request.headers.get("cookie"), workspace.slug); |
| 41 | if (welcome) headers.append("Set-Cookie", clearWelcome(secure)); |
| 42 | return data({ workspace, role, welcome }, { headers }); |
| 43 | } |
| 44 | |
| 45 | /** A workspace's own pages, each with its title and what it is for. */ |
| 46 | const PAGES: Record<string, { title: string; about: string }> = { |
| 47 | workspace: { |
| 48 | title: "Workspace", |
| 49 | about: "Who is in it, what it is on, and what it has spent this month. Everything about the workspace itself is in this sidebar.", |
| 50 | }, |
| 51 | settings: { title: "General", about: "The workspace's name, icon, address and description, who can create teams, and deleting it." }, |
| 52 | people: { |
| 53 | title: "People", |
| 54 | about: "Members create repositories and have the base permission on each one. Owners are Admins on every repository, and also manage members, tokens, billing and integrations.", |
| 55 | }, |
| 56 | projects: { |
| 57 | title: "Projects", |
| 58 | about: "Everything the workspace builds and runs. Search, filter and sort them, and pin the ones you use most to keep them in your sidebar.", |
| 59 | }, |
| 60 | teams: { |
| 61 | title: "Teams", |
| 62 | about: "Groups of members, given roles on repositories together, mentioned as @workspace/team and asked to review together. Child teams inherit their parent's access.", |
| 63 | }, |
| 64 | repositories: { |
| 65 | title: "Repositories", |
| 66 | about: "Every repository in the workspace: who can see it, whether it is archived, and the ones deleted recently.", |
| 67 | }, |
| 68 | tokens: { |
| 69 | title: "Access tokens", |
| 70 | about: "Tokens that belong to the workspace, not a person: for CI, integrations and agents that work for the whole team. Each has the permissions an owner gives it, in all of the workspace's repositories or the ones chosen.", |
| 71 | }, |
| 72 | "personal-access-tokens": { |
| 73 | title: "Personal access tokens", |
| 74 | about: "Which of your members' own tokens may reach the workspace and for how long, the tokens made for it that wait for an owner's approval, and every token that can reach it.", |
| 75 | }, |
| 76 | packages: { |
| 77 | title: "Packages", |
| 78 | about: "What the workspace publishes and installs, beside its code: container images, npm, Composer, Go, Cargo, Maven, NuGet and RubyGems, with the same members, roles and tokens.", |
| 79 | }, |
| 80 | usage: { title: "Usage", about: "What the workspace's agents cost, run by run, by repository, pull request and model." }, |
| 81 | gateway: { |
| 82 | title: "AI Gateway", |
| 83 | about: "Your own code's model requests, sent with the workspace's access tokens: each one's model, tokens, cost and status.", |
| 84 | }, |
| 85 | billing: { title: "Billing and plans", about: "The g1t plan, the trial, your spend limit and caps, prepaying, and every charge." }, |
| 86 | agents: { |
| 87 | title: "Agents", |
| 88 | about: "The workspace's agents together: what they are working on now, what waits on you, and where this month's agent budget went.", |
| 89 | }, |
| 90 | memory: { |
| 91 | title: "Workspace memory", |
| 92 | about: "What holds across all of the workspace's projects, given to every agent in every one of them, beside each project's own memory.", |
| 93 | }, |
| 94 | context: { |
| 95 | title: "Context", |
| 96 | about: "Everything the workspace builds and runs, and what it knows: a catalog built from its projects, memory with a review queue, one search over all of it, and a scorecard for each project.", |
| 97 | }, |
| 98 | audit: { |
| 99 | title: "Audit log", |
| 100 | about: "Every action agents took with their run credentials, and every change people and tokens made: by whom, on whose behalf, to what, and whether it was allowed.", |
| 101 | }, |
| 102 | guardrails: { |
| 103 | title: "Guardrails", |
| 104 | about: "What agents may reach, run, spend and take in every project's sandboxes. Each project can override these under its settings.", |
| 105 | }, |
| 106 | webhooks: { |
| 107 | title: "Webhooks", |
| 108 | about: "Every repository's events, sent to your own addresses as they happen. A repository can also have its own, under its settings.", |
| 109 | }, |
| 110 | secrets: { |
| 111 | title: "Secrets and variables", |
| 112 | about: "Shared with every repository, or the ones you link: read by workflows, deployments, or both. A repository's own row of the same key wins.", |
| 113 | }, |
| 114 | runners: { |
| 115 | title: "Self-hosted runners", |
| 116 | about: "Your own machines, which run workflow jobs, and if you choose agents' work, for free. They connect out to g1t; nothing reaches in.", |
| 117 | }, |
| 118 | security: { |
| 119 | title: "Security", |
| 120 | about: "Open alerts in every project: secrets found in pushes and history, and vulnerable dependencies. Each project's Security page has the details and the security update g1t opened for each.", |
| 121 | }, |
| 122 | integrations: { |
| 123 | title: "Integrations", |
| 124 | about: |
| 125 | "The tools your team works with, connected once for everyone in the workspace and its agents. Secrets are sealed when saved, and agents never see them.", |
| 126 | }, |
| 127 | }; |
| 128 | |
| 129 | /** A workspace page's heading: its title and what it is for. */ |
| 130 | function PageHeader({ title, about }: { title: string; about: string }) { |
| 131 | return ( |
| 132 | <header className="mb-8 border-b border-line pb-6"> |
| 133 | <h1 className="text-2xl font-semibold tracking-tight">{title}</h1> |
| 134 | <p className="mt-1.5 text-sm text-muted">{about}</p> |
| 135 | </header> |
| 136 | ); |
| 137 | } |
| 138 | |
| 139 | export default function WorkspaceLayout({ loaderData }: Route.ComponentProps) { |
| 140 | const { workspace, role, welcome } = loaderData; |
| 141 | const { pathname } = useLocation(); |
| 142 | const going = useNavigation().location; |
| 143 | // The workspace's own page is its overview. Its other pages are found |
| 144 | // from the sidebar, which lights the one you are on, and the top bar's |
| 145 | // trail leads back here; each has a heading of its own. |
| 146 | const here = workspacePage(pathname, workspace.slug); |
| 147 | // On the way to another of its pages: that page's heading and shape |
| 148 | // until it arrives. |
| 149 | const next = going && going.pathname !== pathname ? workspacePage(going.pathname, workspace.slug) : null; |
| 150 | const shown = next ?? here; |
| 151 | const parts = (pathname.split("/-/")[1] ?? "").split("/").filter(Boolean); |
| 152 | // Chat fills the page edge to edge: its conversation, thread and details |
| 153 | // are columns of their own (components/chat/channel.tsx). |
| 154 | if (parts[0] === "chat" && !next) return <Outlet />; |
| 155 | // Code's Overview lays itself out, as Mission control did. |
| 156 | if (parts[0] === "overview" && parts.length === 1 && !next) return <Outlet />; |
| 157 | if (shown !== "overview") { |
| 158 | const key = shown ?? parts[0] ?? ""; |
| 159 | const heading = PAGES[key]; |
| 160 | // A page within one (a single package or team) and a page that is |
| 161 | // coming (Insights) have headings of their own. |
| 162 | const titled = heading && (shown != null || parts.length === 1); |
| 163 | return ( |
| 164 | <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8"> |
| 165 | {titled && ( |
| 166 | // A phone's Agents tab opens on its list of agents (agents/layout.tsx); the fleet's heading would sit above it. |
| 167 | <div className={key === "agents" ? "max-md:hidden" : undefined}> |
| 168 | <PageHeader title={heading.title} about={heading.about} /> |
| 169 | </div> |
| 170 | )} |
| 171 | {next ? <WorkspacePageSkeleton page={next} /> : <Outlet />} |
| 172 | </div> |
| 173 | ); |
| 174 | } |
| 175 | return ( |
| 176 | <> |
| 177 | {/* The workspace's own header band, above its overview. */} |
| 178 | <div className="border-b border-line bg-surface/60"> |
| 179 | <div className="mx-auto max-w-6xl px-4 py-8"> |
| 180 | {welcome && ( |
| 181 | <div className="mb-6"> |
| 182 | <WelcomeBanner title={`You're in ${workspace.name}`}> |
| 183 | You joined as a {role}. Its projects, issues and agents are all here. |
| 184 | </WelcomeBanner> |
| 185 | </div> |
| 186 | )} |
| 187 | <div className="flex flex-wrap items-center gap-4"> |
| 188 | <Avatar name={workspace.slug} image={workspace.avatar} size={52} square /> |
| 189 | <div className="min-w-0 grow"> |
| 190 | <div className="flex flex-wrap items-center gap-2.5"> |
| 191 | <h1 className="truncate text-2xl font-semibold tracking-tight"> |
| 192 | {workspace.name} |
| 193 | </h1> |
| 194 | {role && <Pill>{role}</Pill>} |
| 195 | </div> |
| 196 | <p className="font-mono text-sm text-muted">g1t.sh/{workspace.slug}</p> |
| 197 | </div> |
| 198 | {role ? ( |
| 199 | <ButtonLink to={`/new?workspace=${workspace.slug}`}> |
| 200 | <Plus size={15} /> |
| 201 | New project |
| 202 | </ButtonLink> |
| 203 | ) : ( |
| 204 | // A visitor's sidebar is not this workspace's: its packages |
| 205 | // are a link here, as its projects are below. |
| 206 | <ButtonLink to={`/${workspace.slug}/-/packages`} variant="quiet" prefetch="intent"> |
| 207 | <Package size={15} /> |
| 208 | Packages |
| 209 | </ButtonLink> |
| 210 | )} |
| 211 | </div> |
| 212 | {workspace.description && ( |
| 213 | <p className="mt-4 max-w-2xl text-sm text-muted">{workspace.description}</p> |
| 214 | )} |
| 215 | </div> |
| 216 | </div> |
| 217 | <div className="mx-auto max-w-6xl px-4 py-8"> |
| 218 | {next ? <WorkspacePageSkeleton page="overview" /> : <Outlet />} |
| 219 | </div> |
| 220 | </> |
| 221 | ); |
| 222 | } |