Skip to content
150 linesCodeBlameRaw
1/**
2 * A workspace's pages and where the sidebar finds them. `g1t.sh/<workspace>`
3 * is the workspace's own page, its header and overview; Projects, Packages,
4 * Teams, People, Insights and Settings are pages of their own under `-/`,
5 * each reached from the sidebar and each with its own heading. The sidebar
6 * is always the current workspace's.
7 */
8
9/** The workspace's pages a person moves between, and which the sidebar lists. */
10export type WorkspacePageKey = "overview" | "projects" | "packages" | "teams" | "people" | "insights";
11
12/** Those pages under `-/`: the overview is the workspace's own address. */
13export const WORKSPACE_PAGES = ["projects", "packages", "teams", "people", "insights"] as const;
14
15/**
16 * Which of those pages a path is, under `/<workspace>`: null for the
17 * workspace's other pages (settings, Agent fleet, Usage and the rest), for
18 * one package's or one team's page, and for anything else.
19 */
20export function workspacePage(pathname: string, slug: string): WorkspacePageKey | null {
21 const parts = pagePath(pathname).split("/").filter(Boolean);
22 if (parts[0]?.toLowerCase() !== slug.toLowerCase()) return null;
23 if (parts.length === 1) return "overview";
24 if (parts[1] !== "-" || parts.length !== 3) return null;
25 return (WORKSPACE_PAGES as readonly string[]).includes(parts[2]!) ? (parts[2] as WorkspacePageKey) : null;
26}
27
28/**
29 * The sidebar's rows. The first two are the person's own, whichever
30 * workspace they are in; the rest are the workspace's.
31 */
32export type SidebarKey =
33 | "mission"
34 | "inbox"
35 | "overview"
36 | "projects"
37 | "agents"
38 | "context"
39 | "memory"
40 | "security"
41 | "packages"
42 | "insights"
43 | "people"
44 | "teams"
45 | "usage"
46 | "support"
47 | "settings";
48
49/** The workspace's pages that its Settings row drills into. */
50export const SETTINGS_PAGES = [
51 "settings",
52 "repositories",
53 "tokens",
54 "guardrails",
55 "secrets",
56 "runners",
57 "integrations",
58 "webhooks",
59 "billing",
60 "audit",
61] as const;
62
63/** Pages under `-/` whose sidebar row has another name. */
64const ROW_OF: Record<string, SidebarKey> = {
65 projects: "projects",
66 agents: "agents",
67 context: "context",
68 memory: "memory",
69 security: "security",
70 packages: "packages",
71 insights: "insights",
72 people: "people",
73 teams: "teams",
74 usage: "usage",
75 // The AI Gateway's requests are usage.
76 gateway: "usage",
77};
78
79/**
80 * The sidebar row that is current on a path: one row at most, so the
81 * sidebar always says where you are. `slug` is the workspace the sidebar
82 * is about; another workspace's pages light nothing.
83 */
84export function sidebarCurrent(pathname: string, slug: string | null): SidebarKey | null {
85 const path = pagePath(pathname);
86 if (path === "/") return "mission";
87 if (path === "/inbox" || path.startsWith("/inbox/")) return "inbox";
88 if (path === "/support" || path.startsWith("/support/")) return "support";
89 const parts = path.split("/").filter(Boolean);
90 if (!slug || parts[0]?.toLowerCase() !== slug.toLowerCase()) return null;
91 if (parts.length === 1) return "overview";
92 if (parts[1] !== "-") return null;
93 const page = parts[2] ?? "";
94 if ((SETTINGS_PAGES as readonly string[]).includes(page)) return "settings";
95 return ROW_OF[page] ?? null;
96}
97
98/** `?tab=` as people write it, from the tabs the workspace page once had: the page it means. */
99const TAB_WORDS: Record<string, string> = {
100 overview: "",
101 projects: "-/projects",
102 repositories: "-/projects",
103 repos: "-/projects",
104 packages: "-/packages",
105 people: "-/people",
106 members: "-/people",
107 teams: "-/teams",
108 insights: "-/insights",
109 settings: "-/settings",
110};
111
112/** Workspace pages that moved, by their old name under `-/`. */
113const MOVED: Record<string, string> = {
114 members: "-/people",
115 overview: "",
116 "soon/teams": "-/teams",
117 "soon/insights": "-/insights",
118};
119
120/** The page a request is for: a click's data request ends in `.data`. */
121export function pagePath(pathname: string): string {
122 return pathname.replace(/\.data$/, "").replace(/\/+$/, "") || "/";
123}
124
125/**
126 * Where an old address of a workspace's pages is now, keeping its query;
127 * null when it has not moved. `/<workspace>?tab=projects` and the like
128 * open that page.
129 */
130export function workspaceRedirect(pathname: string, search = ""): string | null {
131 // A click asks for the page's data at `<path>.data?_routes=…`; the page
132 // is the same one a full load asks for.
133 const params = new URLSearchParams(search);
134 params.delete("_routes");
135 search = params.toString();
136 const parts = pagePath(pathname).split("/").filter(Boolean);
137 const slug = parts[0];
138 if (!slug) return null;
139 if (parts.length === 1 && params.has("tab")) {
140 const to = TAB_WORDS[(params.get("tab") ?? "").toLowerCase()];
141 if (to === undefined) return null;
142 params.delete("tab");
143 const rest = params.toString();
144 return `/${slug}${to ? `/${to}` : ""}${rest ? `?${rest}` : ""}`;
145 }
146 if (parts[1] !== "-") return null;
147 const to = MOVED[parts.slice(2).join("/")];
148 if (to === undefined) return null;
149 return `/${slug}${to ? `/${to}` : ""}${search && search !== "?" ? (search.startsWith("?") ? search : `?${search}`) : ""}`;
150}