Skip to content

Commit

The sidebar says where you are; the workspace page loses its tabs

The workspace page's tab bar repeated the sidebar. It is gone: the page is the workspace's header and its overview, and Projects, Packages, Teams, People, Insights and Settings are pages of their own, each with a heading, reached from the sidebar, with the top bar's trail leading back. The sidebar now has two parts a rule apart. Above it is what is yours in every workspace: Mission control, your Inbox with its unread count, and what is shared with you. Below it, under the workspace's name: Overview, the projects, Agent fleet to Packages with Insights (soon), then People, Teams, Usage, Support and Settings. lib/workspace-nav.ts decides which one row is lit, on every workspace page and within a team or a package, and its tests check each. A visitor reaches Packages from the workspace's page. Old ?tab= addresses still redirect, now including settings.

syntaqxcommitted Parent3d799f0Browse files
10 files+293−2350/10 viewed
+3−3
2626
2727 A workspace's page, `g1t.sh/<workspace>`, shows your pinned projects and
2828 its most active ones, each with where it is deployed, its latest build, and
29−its open issues and pull requests. Its **Projects** tab lists every one,
30−with search, filters and sorting; see
31−[the Projects tab](/guides/workspaces/#the-projects-tab). The sidebar keeps
29+its open issues and pull requests. **All projects** in the sidebar lists
30+every one, with search, filters and sorting; see
31+[the Projects page](/guides/workspaces/#the-projects-page). The sidebar keeps
3232 your [pinned and recent projects](/guides/workspaces/#pinned-and-recent-projects).
3333
3434 ## A project's pages
+3−4
1717 Any member of the workspace with a confirmed email address can create a
1818 team, and becomes its first maintainer.
1919
20−1. Open **Teams** in the sidebar, or the **Teams** tab of the workspace's
21− page: `g1t.sh/<workspace>/-/teams`.
20+1. Open **Teams** in the sidebar: `g1t.sh/<workspace>/-/teams`.
2221 2. Choose **New team**, or go to `g1t.sh/<workspace>/-/teams/new`. The
2322 **+** menu in the top bar has **New team** too, for the workspace the
2423 sidebar is on.
3938 | Description | Up to 280 characters. |
4039 | Teams in one workspace | Up to 500. |
4140
42−The **Teams** tab lists the teams you can see, yours first, then by
41+The **Teams** page lists the teams you can see, yours first, then by
4342 name, with a search box that matches names and slugs. A person's teams
44−also show beside them on the workspace's **People** tab.
43+also show beside them on the workspace's **People** page.
4544
4645 ## Visibility
4746
+35−20
270270 | Owner | Everything a member can, and manage members, the base permission, the workspace's access tokens, its details, and billing: the plan, card checks, prepayment and limits. Admin on every repository, and the only ones who can transfer and delete them; see [access and roles](/guides/access-and-roles/). |
271271
272272 Whoever creates a workspace is its owner. An owner adds people on the
273−workspace's **People**, `g1t.sh/<workspace>/-/people` (a tab of the
274−workspace's page, and in the sidebar):
273+workspace's **People**, `g1t.sh/<workspace>/-/people` (in the sidebar):
275274
276275 - **By username**: someone already on g1t joins at once, as a member.
277276 - **By email address**: g1t emails an invite that only that address can
298297 ## The workspace's page
299298
300299 A workspace's own page, `g1t.sh/<workspace>`, has its icon, name, address
301−and description at the top, and tabs under them:
300+and description at the top, then its overview: your
301+[pinned projects](#pinned-and-recent-projects), then the most active ones,
302+the pull requests in progress across them, and **All projects**. Members
303+also see a **Usage** card with this month's spend, and who belongs.
302304
303−| Tab | Address | Who | |
305+The workspace's other pages each have a heading of their own and a row in
306+[the sidebar](#the-sidebar), lit while you are on them. The trail in the
307+top bar, such as *acme / Projects*, leads back to the workspace's page.
308+
309+| Page | Address | Who | |
304310 | --- | --- | --- | --- |
305−| **Overview** | `g1t.sh/<workspace>` | Everyone | Your [pinned projects](#pinned-and-recent-projects), then the most active ones, the pull requests in progress across them, and **All projects**. Members also see a **Usage** card with this month's spend, and who belongs. |
306−| **Projects** | `/-/projects` | Everyone | Every project you can see, with their count. See [the Projects tab](#the-projects-tab). |
307−| [**Packages**](/guides/packages/) | `/-/packages` | Everyone | What the workspace publishes. |
311+| **Overview** | `g1t.sh/<workspace>` | Everyone | The page above. |
312+| **Projects** | `/-/projects` | Everyone | Every project you can see. See [the Projects page](#the-projects-page). |
313+| [**Packages**](/guides/packages/) | `/-/packages` | Everyone | What the workspace publishes. A visitor opens it from **Packages** on the workspace's page. |
308314 | [**Teams**](/guides/teams/) | `/-/teams` | Members | Groups of members given roles on repositories together, mentioned as `@workspace/team` and asked to review together. Each team has its own page at `/-/teams/<team>`. |
309−| **People** | `/-/people` | Members | Who belongs, with their count. Owners add and remove people here. |
315+| **People** | `/-/people` | Members | Who belongs. Owners add and remove people here. |
310316 | **Insights** | `/-/insights` | Members | Coming soon: how the whole workspace delivers. |
311317 | **Settings** | `/-/settings` | Owners | How the workspace is set up and connected (below). |
312318
317323
318324 Older addresses still work: `/-/members` opens People, and
319325 `g1t.sh/<workspace>?tab=projects` (or `repositories`, `packages`,
320−`people`) opens that tab.
326+`teams`, `people`, `insights` or `settings`) opens that page.
321327
322−### The Projects tab
328+### The Projects page
323329
324−The Projects tab is made for workspaces with hundreds of projects:
330+The Projects page, `g1t.sh/<workspace>/-/projects`, is made for workspaces with hundreds of projects:
325331
326332 - **Find a project** matches every word you type in a project's name, its
327333 address or its description. Press <kbd>/</kbd> anywhere on the page to
353359 [Explore](https://g1t.sh/explore), public projects from all of g1t, is in
354360 the top bar, beside **Docs**.
355361
356−In order, it lists **Mission control**; the workspace's
357−[projects](#pinned-and-recent-projects); under **Workspace**, the places work
358−happens across them (**Agent fleet**, **Context**, **Memory**, **Security**
359−and [**Packages**](/guides/packages/), with **Boards** and **Roadmap**
360−soon); then **People**, [**Teams**](/guides/teams/), **Usage**, what g1t's runs have
361−cost (see [usage and billing](/guides/usage-and-billing/)), **Support** and
362−**Settings**. An item with an arrow opens a list of its own in the sidebar:
362+It has two parts, a rule apart. Above the rule is what is yours in every
363+workspace: **Mission control**, your **Inbox** with how many items are
364+unread, and the repositories **Shared with you** in workspaces you do not
365+belong to. Below it, under the workspace's name, is the workspace:
366+
367+1. **Overview**, the [workspace's page](#the-workspaces-page).
368+2. Its [projects](#pinned-and-recent-projects), ending with **All projects**.
369+3. The places work happens across them: **Agent fleet**, **Context**,
370+ **Memory**, **Security** and [**Packages**](/guides/packages/), with
371+ **Insights**, **Boards** and **Roadmap** soon.
372+4. **People**, [**Teams**](/guides/teams/), **Usage**, what g1t's runs have
373+ cost (see [usage and billing](/guides/usage-and-billing/)), **Support**
374+ and **Settings**.
375+
376+One row is lit wherever you are: **Teams** on a team's pages, **Packages**
377+on a package's, and **Settings** on every page it opens. An item with an arrow opens a list of its own in the sidebar:
363378 **Settings** slides over to how the workspace is set up and connected, and
364379 the row at the top, **‹ Settings**, slides back:
365380
396411
397412 - **Pinned**: the projects you pinned, in your order, up to eight a
398413 workspace. Pin one with **Pin** on its page, or the pin on its row of the
399− Projects tab or its card on the Overview. Drag a pinned project to move
414+ Projects page or its card on the Overview. Drag a pinned project to move
400415 it, or hold <kbd>Alt</kbd> and press the up or down arrow.
401416 - **Recent**: the projects you opened last that you have not pinned, up to
402417 five.
403−- **All projects**, with how many there are, opens the Projects tab.
418+- **All projects**, with how many there are, opens the Projects page.
404419
405420 Pins and recent projects are yours: nobody else sees them, and each
406421 workspace has its own. ⌘K finds any project in the workspace, pinned or not.
+74−36
1−import { Activity, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, KanbanSquare, KeyRound, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Sparkles, Ticket, Users, UsersRound, Webhook, X } from "lucide-react";
1+import { Activity, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, Inbox, KanbanSquare, KeyRound, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Sparkles, Ticket, TrendingUp, Users, UsersRound, Webhook, X } from "lucide-react";
22 import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
33 import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
2222 DropdownMenuSeparator,
2323 DropdownMenuTrigger,
2424 } from "./ui/dropdown-menu";
25−import { type RoadmapItem, roadmapIn } from "../lib/roadmap";
25+import { type RoadmapItem, roadmapIn, roadmapItem } from "../lib/roadmap";
26+import { SETTINGS_PAGES, sidebarCurrent } from "../lib/workspace-nav";
2627 import { SETTINGS_CAPABILITY, type ViewerAccess, seesSettings } from "../lib/access";
2728 import { VISITOR_LINKS, projectPages } from "../lib/chrome";
2829 import { ACCOUNT_SETTINGS, type AccountSettingsPage, FIRST_SETTINGS_PAGE, accountSettingsPage } from "../lib/account-settings";
8283 count,
8384 also,
8485 drill,
86+ current: lit,
8587 children,
8688 }: {
8789 to: string;
9294 also?: string | string[];
9395 /** It opens a list of its own: a chevron says so, always or on hover. */
9496 drill?: boolean | "hover";
97+ /** Whether it is the current row, when the list works that out itself. */
98+ current?: boolean;
9599 children: ReactNode;
96100 }) {
97101 const { pathname } = useLocation();
102106 prefetch="intent"
103107 className={({ isActive, isPending }) => {
104108 const current =
105− isActive || [also ?? []].flat().some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
109+ lit ??
110+ (isActive || [also ?? []].flat().some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/")));
106111 return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
107112 current
108113 ? "bg-raised font-medium text-fg"
157162 also,
158163 icon,
159164 about,
165+ current: lit,
160166 children,
161167 }: {
162168 to: string;
163169 also?: string[];
164170 icon: ReactNode;
165171 about: string;
172+ /** Whether it is the current row, when the list works that out itself. */
173+ current?: boolean;
166174 children: ReactNode;
167175 }) {
168176 const { pathname } = useLocation();
172180 title={about}
173181 prefetch="intent"
174182 className={({ isActive }) => {
175− const current = isActive || (also ?? []).some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
183+ const current = lit ?? (isActive || (also ?? []).some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/")));
176184 return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
177185 current ? "bg-raised font-medium text-fg" : "text-faint hover:bg-raised/60 hover:text-muted"
178186 }`;
471479 * still open these.
472480 */
473481 const SETTINGS_PAGE =
474− /^\/([^/]+)\/-\/(settings|repositories|tokens|guardrails|secrets|runners|integrations|webhooks|billing|audit)(\/|$)/;
482+ new RegExp(`^/([^/]+)/-/(${SETTINGS_PAGES.join("|")})(/|$)`);
475483 /** A project's settings pages, which the project's menu drills into. */
476484 const REPO_SETTINGS_PAGE = /^\/([^/]+)\/([^/-][^/]*)\/settings(\/|$)/;
477485
583591 * way to all of them. Pins move by dragging, or with Alt and the arrow
584592 * keys; the projects service keeps the order.
585593 */
586−function SidebarProjects({ slug, shell }: { slug: string; shell: ShellData }) {
594+function SidebarProjects({ slug, shell, current }: { slug: string; shell: ShellData; current: boolean }) {
587595 const reorder = useFetcher({ key: `pins:${slug}` });
588596 const saved = shell.pinned ?? [];
589597 // While a new order is on its way, it shows as made.
680688 </>
681689 )}
682690 <div className="pt-1">
683− <SidebarLink to={`/${slug}/-/projects`} icon={<LayoutGrid size={15} />} count={shell.repos.length}>
691+ <SidebarLink to={`/${slug}/-/projects`} icon={<LayoutGrid size={15} />} count={shell.repos.length} current={current}>
684692 All projects
685693 </SidebarLink>
686694 </div>
689697 }
690698
691699 /**
692− * The main list: where you go, the projects, what the workspace builds and
693− * runs with across them, then its usage, support and settings.
700+ * The main list, in two parts a rule apart. Above it, what is yours
701+ * whichever workspace you are in: Mission control, your inbox, and what
702+ * others have shared with you. Below it, under the workspace's name, the
703+ * workspace: its overview, its projects, what it builds and runs with
704+ * across them, then its people, usage, support and settings. One row is
705+ * lit wherever you are (lib/workspace-nav.ts).
694706 */
695707 function MainMenu({ user, shell }: { user: User | null; shell: ShellData }) {
696708 const ws = shell.workspace;
709+ const { pathname } = useLocation();
710+ const going = useNavigation().location?.pathname;
711+ // The row lights as the link is followed, not once the page arrives.
712+ const here = sidebarCurrent(going ?? pathname, ws?.slug ?? null);
697713 // A visitor browses: no workspace, no projects of their own.
698714 if (!user) {
699715 return (
708724 </nav>
709725 );
710726 }
727+ const shared = shell.shared ?? [];
711728 return (
712729 <nav aria-label="g1t" className={PANEL}>
713− {/* Everything here is the workspace the switcher names: its home first.
714− The workspace's own page is the switcher's; Explore, all of g1t,
715− is in the top bar. */}
730+ {/* Yours, in every workspace. Explore, all of g1t, is in the top bar. */}
716731 <div className="mt-3 space-y-px">
717− <SidebarLink to="/" end icon={<House size={15} />}>
732+ <SidebarLink to="/" end icon={<House size={15} />} current={here === "mission"}>
718733 Mission control
719734 </SidebarLink>
735+ <SidebarLink to="/inbox" icon={<Inbox size={15} />} count={shell.inbox?.unread ?? undefined} current={here === "inbox"}>
736+ Inbox
737+ </SidebarLink>
720738 </div>
721−
722− <Rule />
723− {ws ? (
724− <SidebarProjects slug={ws.slug} shell={shell} />
725− ) : (
726− <SidebarGroup title="Projects">
727− <p className="px-2 py-1 text-xs text-faint">None yet.</p>
728− </SidebarGroup>
729− )}
730− {(shell.shared ?? []).length > 0 && (
739+ {shared.length > 0 && (
731740 <SidebarGroup title="Shared with you" className="mt-3">
732− {(shell.shared ?? []).map((repo) => (
741+ {shared.map((repo) => (
733742 <SidebarLink
734743 key={`${repo.namespace}/${repo.name}`}
735744 to={`/${repo.namespace}/${repo.name}`}
743752 </SidebarGroup>
744753 )}
745754
746− {ws && (
755+ <Rule />
756+ {ws ? (
747757 <>
758+ {/* The workspace the switcher names: its own page first. */}
759+ <SidebarGroup title={displayName(ws)}>
760+ <SidebarLink to={`/${ws.slug}`} end icon={<LayoutDashboard size={15} />} current={here === "overview"}>
761+ Overview
762+ </SidebarLink>
763+ </SidebarGroup>
764+ <div className="mt-3">
765+ <SidebarProjects slug={ws.slug} shell={shell} current={here === "projects"} />
766+ </div>
767+
748768 <Rule />
749− <SidebarGroup title="Workspace">
750− <SidebarLink to={`/${ws.slug}/-/agents`} icon={<Bot size={15} />}>
769+ <div className="space-y-px">
770+ <SidebarLink to={`/${ws.slug}/-/agents`} icon={<Bot size={15} />} current={here === "agents"}>
751771 Agent fleet
752772 </SidebarLink>
753− <SidebarLink to={`/${ws.slug}/-/context`} icon={<Network size={15} />}>
773+ <SidebarLink to={`/${ws.slug}/-/context`} icon={<Network size={15} />} current={here === "context"}>
754774 Context
755775 </SidebarLink>
756− <SidebarLink to={`/${ws.slug}/-/memory`} icon={<Brain size={15} />}>
776+ <SidebarLink to={`/${ws.slug}/-/memory`} icon={<Brain size={15} />} current={here === "memory"}>
757777 Memory
758778 </SidebarLink>
759− <SidebarLink to={`/${ws.slug}/-/security`} icon={<ShieldCheck size={15} />}>
779+ <SidebarLink to={`/${ws.slug}/-/security`} icon={<ShieldCheck size={15} />} current={here === "security"}>
760780 Security
761781 </SidebarLink>
762− <SidebarLink to={`/${ws.slug}/-/packages`} icon={<Package size={15} />}>
782+ <SidebarLink to={`/${ws.slug}/-/packages`} icon={<Package size={15} />} current={here === "packages"}>
763783 Packages
764784 </SidebarLink>
785+ {INSIGHTS && (
786+ <SidebarSoonLink
787+ to={`/${ws.slug}/-/insights`}
788+ icon={<TrendingUp size={15} />}
789+ about={INSIGHTS.summary}
790+ current={here === "insights"}
791+ >
792+ {INSIGHTS.title}
793+ </SidebarSoonLink>
794+ )}
765795 {roadmapIn("Workspace").filter((item) => item.key !== "insights").map((item) => (
766796 <SidebarSoonLink
767797 key={item.key}
772802 {item.title === "Board" ? "Boards" : item.title}
773803 </SidebarSoonLink>
774804 ))}
775− </SidebarGroup>
805+ </div>
776806
777807 <Rule />
778808 <div className="space-y-px">
779809 {/* Who belongs, for every member to see; owners invite and manage there. */}
780− <SidebarLink to={`/${ws.slug}/-/people`} icon={<Users size={15} />}>
810+ <SidebarLink to={`/${ws.slug}/-/people`} icon={<Users size={15} />} current={here === "people"}>
781811 People
782812 </SidebarLink>
783− <SidebarLink to={`/${ws.slug}/-/teams`} icon={WORKSPACE_ICONS.teams}>
813+ <SidebarLink to={`/${ws.slug}/-/teams`} icon={WORKSPACE_ICONS.teams} current={here === "teams"}>
784814 Teams
785815 </SidebarLink>
786− <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
816+ <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />} current={here === "usage"}>
787817 Usage
788818 </SidebarLink>
789− <SidebarLink to="/support" icon={<LifeBuoy size={15} />}>
819+ <SidebarLink to="/support" icon={<LifeBuoy size={15} />} current={here === "support"}>
790820 Support
791821 </SidebarLink>
792822 {/* How it is set up and connected, what it pays and its record: a list of their own. */}
794824 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/repositories`}
795825 icon={<Settings size={15} />}
796826 drill
827+ current={here === "settings"}
797828 >
798829 Settings
799830 </SidebarLink>
800831 </div>
801832 </>
833+ ) : (
834+ <SidebarGroup title="Projects">
835+ <p className="px-2 py-1 text-xs text-faint">None yet.</p>
836+ </SidebarGroup>
802837 )}
803838 </nav>
804839 );
805840 }
806841
842+/** Insights, coming: the workspace's own page says what it will be. */
843+const INSIGHTS = roadmapItem("insights");
844+
807845 /** A workspace's settings, drilled into from Settings in the main list. */
808846 function SettingsMenu({ slug, owner }: { slug: string; owner: boolean }) {
809847 return (
+6−6
11 /**
2− * The shapes of a workspace's tabs while they load: the same layout as
2+ * The shapes of a workspace's pages while they load: the same layout as
33 * the real thing, so nothing moves when it arrives (components/ui/skeleton.tsx).
44 */
5−import type { WorkspaceTabKey } from "../lib/workspace-nav";
5+import type { WorkspacePageKey } from "../lib/workspace-nav";
66 import { Skeleton, SkeletonRows, SkeletonText } from "./ui/skeleton";
77
88 /** A project card, as the Overview and the Projects grid show them. */
8585 );
8686 }
8787
88−/** Whichever tab is on its way. */
89−export function WorkspaceTabSkeleton({ tab }: { tab: WorkspaceTabKey }) {
90− if (tab === "overview") return <OverviewSkeleton />;
91− if (tab === "projects") return <ProjectsSkeleton />;
88+/** Whichever page is on its way. */
89+export function WorkspacePageSkeleton({ page }: { page: WorkspacePageKey }) {
90+ if (page === "overview") return <OverviewSkeleton />;
91+ if (page === "projects") return <ProjectsSkeleton />;
9292 return (
9393 <div aria-busy="true" aria-label="Loading" className="space-y-4">
9494 <Skeleton className="h-3.5 w-2/3 max-w-xl" />
+41−27
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { pagePath, workspaceRedirect, workspaceTab, workspaceTabs } from "./workspace-nav.ts";
4+import { SETTINGS_PAGES, WORKSPACE_PAGES, pagePath, sidebarCurrent, workspacePage, workspaceRedirect } from "./workspace-nav.ts";
55
6−test("everyone sees Overview, Projects and Packages; members and owners see more", () => {
7− const keys = (member: boolean, owner: boolean) => workspaceTabs("acme", { member, owner }).map((tab) => tab.key);
8− assert.deepEqual(keys(false, false), ["overview", "projects", "packages"]);
9− assert.deepEqual(keys(true, false), ["overview", "projects", "packages", "teams", "people", "insights"]);
10− assert.deepEqual(keys(true, true), ["overview", "projects", "packages", "teams", "people", "insights", "settings"]);
6+test("a path is one of the workspace's pages, or none", () => {
7+ assert.equal(workspacePage("/acme", "acme"), "overview");
8+ assert.equal(workspacePage("/Acme/", "acme"), "overview");
9+ assert.equal(workspacePage("/acme.data", "acme"), "overview");
10+ assert.equal(workspacePage("/acme/-/projects", "acme"), "projects");
11+ assert.equal(workspacePage("/acme/-/people", "acme"), "people");
12+ assert.equal(workspacePage("/acme/-/packages", "acme"), "packages");
13+ assert.equal(workspacePage("/acme/-/teams", "acme"), "teams");
14+ assert.equal(workspacePage("/acme/-/insights", "acme"), "insights");
15+ // One package or team has a page of its own; settings and the rest have headings.
16+ assert.equal(workspacePage("/acme/-/packages/npm/web", "acme"), null);
17+ assert.equal(workspacePage("/acme/-/teams/web", "acme"), null);
18+ assert.equal(workspacePage("/acme/-/settings", "acme"), null);
19+ assert.equal(workspacePage("/acme/-/agents", "acme"), null);
20+ assert.equal(workspacePage("/acme/web", "acme"), null);
21+ assert.equal(workspacePage("/other", "acme"), null);
1122 });
1223
13−test("tabs carry counts, and say which are coming", () => {
14− const tabs = workspaceTabs("acme", { member: true, owner: false, projects: 412, people: 7 });
15− assert.equal(tabs.find((tab) => tab.key === "projects")?.count, 412);
16− assert.equal(tabs.find((tab) => tab.key === "people")?.count, 7);
17− assert.equal(tabs.find((tab) => tab.key === "teams")?.soon, undefined);
18− assert.equal(tabs.find((tab) => tab.key === "insights")?.soon, true);
19− assert.equal(tabs.find((tab) => tab.key === "overview")?.to, "/acme");
20− assert.equal(tabs.find((tab) => tab.key === "projects")?.to, "/acme/-/projects");
24+test("every page that once had a tab has a sidebar row", () => {
25+ for (const page of ["overview", ...WORKSPACE_PAGES] as const) {
26+ const path = page === "overview" ? "/acme" : `/acme/-/${page}`;
27+ assert.equal(sidebarCurrent(path, "acme"), page, path);
28+ }
2129 });
2230
23−test("a path is a tab, or one of the workspace's other pages", () => {
24− assert.equal(workspaceTab("/acme", "acme"), "overview");
25− assert.equal(workspaceTab("/Acme/", "acme"), "overview");
26− assert.equal(workspaceTab("/acme/-/projects", "acme"), "projects");
27− assert.equal(workspaceTab("/acme/-/people", "acme"), "people");
28− assert.equal(workspaceTab("/acme/-/packages", "acme"), "packages");
29− // One package has a page of its own; settings and the rest have headings.
30− assert.equal(workspaceTab("/acme/-/packages/npm/web", "acme"), null);
31− assert.equal(workspaceTab("/acme/-/settings", "acme"), null);
32− assert.equal(workspaceTab("/acme/-/agents", "acme"), null);
33− assert.equal(workspaceTab("/acme/web", "acme"), null);
34− assert.equal(workspaceTab("/other", "acme"), null);
31+test("one sidebar row is current wherever you are in the workspace", () => {
32+ assert.equal(sidebarCurrent("/", "acme"), "mission");
33+ assert.equal(sidebarCurrent("/inbox", "acme"), "inbox");
34+ assert.equal(sidebarCurrent("/support", "acme"), "support");
35+ assert.equal(sidebarCurrent("/acme/-/projects.data", "acme"), "projects");
36+ // Within a team or a package, its row stays lit.
37+ assert.equal(sidebarCurrent("/acme/-/teams/web/settings", "acme"), "teams");
38+ assert.equal(sidebarCurrent("/acme/-/packages/npm/web", "acme"), "packages");
39+ assert.equal(sidebarCurrent("/acme/-/agents", "acme"), "agents");
40+ assert.equal(sidebarCurrent("/acme/-/usage", "acme"), "usage");
41+ // Every page the Settings row drills into.
42+ for (const page of SETTINGS_PAGES) assert.equal(sidebarCurrent(`/acme/-/${page}`, "acme"), "settings", page);
43+ // Another workspace's pages, a project and a person light nothing here.
44+ assert.equal(sidebarCurrent("/other/-/projects", "acme"), null);
45+ assert.equal(sidebarCurrent("/acme/web", "acme"), null);
46+ assert.equal(sidebarCurrent("/u/ada", "acme"), null);
47+ assert.equal(sidebarCurrent("/acme", null), null);
3548 });
3649
3750 test("old addresses go to where their pages are now", () => {
4457 assert.equal(workspaceRedirect("/acme/web"), null);
4558 });
4659
47−test("?tab= opens that tab, keeping the rest of the query", () => {
60+test("?tab= opens that page, keeping the rest of the query", () => {
4861 assert.equal(workspaceRedirect("/acme", "?tab=repositories"), "/acme/-/projects");
4962 assert.equal(workspaceRedirect("/acme", "?tab=projects&q=api"), "/acme/-/projects?q=api");
5063 assert.equal(workspaceRedirect("/acme", "?tab=members"), "/acme/-/people");
5164 assert.equal(workspaceRedirect("/acme", "?tab=overview"), "/acme");
65+ assert.equal(workspaceRedirect("/acme", "?tab=settings"), "/acme/-/settings");
5266 assert.equal(workspaceRedirect("/acme", "?tab=nonsense"), null);
5367 assert.equal(workspaceRedirect("/acme", ""), null);
5468 });
+84−48
11 /**
2− * A workspace's own page at `g1t.sh/<workspace>`: its header, and tabs for
3− * what it has (Overview, Projects, Packages, Teams, People, Insights and,
4− * for owners, Settings). The sidebar is always the current workspace's;
5− * this page is where the workspace itself is shown, to members and to
6− * everyone else.
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.
77 */
88
9−export type WorkspaceTabKey = "overview" | "projects" | "packages" | "teams" | "people" | "insights" | "settings";
9+/** The workspace's pages a person moves between, and which the sidebar lists. */
10+export type WorkspacePageKey = "overview" | "projects" | "packages" | "teams" | "people" | "insights";
1011
11−export type WorkspaceTab = {
12− key: WorkspaceTabKey;
13− label: string;
14− to: string;
15− /** Shown beside the label; left out when unknown. */
16− count?: number | null;
17− /** Not built yet: its tab opens what it will be. */
18− soon?: boolean;
19−};
12+/** Those pages under `-/`: the overview is the workspace's own address. */
13+export const WORKSPACE_PAGES = ["projects", "packages", "teams", "people", "insights"] as const;
2014
21−/** The workspace's pages under `-/` that are drawn under its tabs. */
22−export const TAB_PAGES = ["projects", "packages", "teams", "people", "insights"] as const;
23−
24−/** Tabs whose pages say what they will be, until they are built. */
25−export const SOON_TABS = new Set<WorkspaceTabKey>(["insights"]);
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+ */
20+export 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+}
2627
2728 /**
28− * The tabs, in order. People, Teams and Insights are for members, and
29− * Settings for owners; everyone sees the rest.
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.
3031 */
31−export function workspaceTabs(
32− slug: string,
33− options: { member: boolean; owner: boolean; projects?: number | null; people?: number | null },
34−): WorkspaceTab[] {
35− const base = `/${slug}`;
36− const tabs: (WorkspaceTab | false)[] = [
37− { key: "overview", label: "Overview", to: base },
38− { key: "projects", label: "Projects", to: `${base}/-/projects`, count: options.projects ?? null },
39− { key: "packages", label: "Packages", to: `${base}/-/packages` },
40− options.member && { key: "teams", label: "Teams", to: `${base}/-/teams` },
41− options.member && { key: "people", label: "People", to: `${base}/-/people`, count: options.people ?? null },
42− options.member && { key: "insights", label: "Insights", to: `${base}/-/insights`, soon: true },
43− options.owner && { key: "settings", label: "Settings", to: `${base}/-/settings` },
44− ];
45− return tabs.filter((tab): tab is WorkspaceTab => tab !== false);
46−}
32+export 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. */
50+export 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. */
64+const 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+};
4776
4877 /**
49− * Which tab a path is, under `/<workspace>`: null for the workspace's other
50− * pages (its settings, Agent fleet, Usage and the rest), which have a page
51− * heading of their own, and for one package's page.
78+ * The sidebar row that is current on a path: one row at most, so the
79+ * sidebar always says where you are. `slug` is the workspace the sidebar
80+ * is about; another workspace's pages light nothing.
5281 */
53−export function workspaceTab(pathname: string, slug: string): WorkspaceTabKey | null {
54− const parts = pathname.split("/").filter(Boolean);
55− if (parts[0]?.toLowerCase() !== slug.toLowerCase()) return null;
82+export function sidebarCurrent(pathname: string, slug: string | null): SidebarKey | null {
83+ const path = pagePath(pathname);
84+ if (path === "/") return "mission";
85+ if (path === "/inbox" || path.startsWith("/inbox/")) return "inbox";
86+ if (path === "/support" || path.startsWith("/support/")) return "support";
87+ const parts = path.split("/").filter(Boolean);
88+ if (!slug || parts[0]?.toLowerCase() !== slug.toLowerCase()) return null;
5689 if (parts.length === 1) return "overview";
57− if (parts[1] !== "-" || parts.length !== 3) return null;
58− return (TAB_PAGES as readonly string[]).includes(parts[2]!) ? (parts[2] as WorkspaceTabKey) : null;
90+ if (parts[1] !== "-") return null;
91+ const page = parts[2] ?? "";
92+ if ((SETTINGS_PAGES as readonly string[]).includes(page)) return "settings";
93+ return ROW_OF[page] ?? null;
5994 }
6095
61−/** `?tab=` as people write it, from habit: the tab it means. */
96+/** `?tab=` as people write it, from the tabs the workspace page once had: the page it means. */
6297 const TAB_WORDS: Record<string, string> = {
6398 overview: "",
6499 projects: "-/projects",
69104 members: "-/people",
70105 teams: "-/teams",
71106 insights: "-/insights",
107+ settings: "-/settings",
72108 };
73109
74110 /** Workspace pages that moved, by their old name under `-/`. */
87123 /**
88124 * Where an old address of a workspace's pages is now, keeping its query;
89125 * null when it has not moved. `/<workspace>?tab=projects` and the like
90− * open that tab.
126+ * open that page.
91127 */
92128 export function workspaceRedirect(pathname: string, search = ""): string | null {
93129 // A click asks for the page's data at `<path>.data?_routes=…`; the page
+1−1
6262 // A workspace's own pages sit under `-`, which no repository can be named.
6363 route(":owner", "routes/workspace/layout.tsx", [
6464 index("routes/workspace/overview.tsx"),
65− // Its tabs: Overview (above), Projects, Packages, Teams, People, Insights.
65+ // Its pages, each in the sidebar: Overview (above), Projects, People, Insights; Teams and Packages below.
6666 route("-/projects", "routes/workspace/projects.tsx"),
6767 route("-/people", "routes/workspace/people.tsx"),
6868 route("-/insights", "routes/workspace/tab-soon.tsx", { id: "routes/workspace/insights" }),
+43−87
1−import { Plus } from "lucide-react";
2−import { NavLink, Outlet, data, redirect, useLocation, useNavigation } from "react-router";
1+import { Package, Plus } from "lucide-react";
2+import { Outlet, data, redirect, useLocation, useNavigation } from "react-router";
33
44 import type { Route } from "./+types/layout";
55 import { page } from "../../lib/meta";
99 import { notFound } from "../../lib/not-found.server";
1010 import { redirectIfRenamed } from "../../lib/renamed.server";
1111 import { rememberWorkspace } from "../../lib/workspace-choice";
12−import { type WorkspaceTab, type WorkspaceTabKey, workspaceRedirect, workspaceTab, workspaceTabs } from "../../lib/workspace-nav";
13−import { workspaceProjects } from "../../lib/workspace-projects.server";
14−import { WorkspaceTabSkeleton } from "../../components/workspace-skeletons";
12+import { workspacePage, workspaceRedirect } from "../../lib/workspace-nav";
13+import { WorkspacePageSkeleton } from "../../components/workspace-skeletons";
1514 import { identity } from "../../lib/services.server";
1615 import { getViewer, roleIn } from "../../lib/session.server";
1716
2120
2221 export async function loader({ params, context, request }: Route.LoaderArgs) {
2322 const url = new URL(request.url);
24− // `?tab=projects` and the like, from habit: that tab's own address.
23+ // `?tab=projects` and the like, from the tabs this page once had: that
24+ // page's own address.
2525 const moved = url.search ? workspaceRedirect(url.pathname, url.search) : null;
2626 if (moved) throw redirect(moved);
2727 const viewer = getViewer(context);
28− // Under the tabs, the count of projects beside Projects: the same
29− // listing the sidebar and the Projects page read, asked at once.
30− const tabbed = workspaceTab(url.pathname, params.owner) != null;
31− const [workspace, listed] = await Promise.all([
32− identity.getWorkspace(params.owner),
33− tabbed ? workspaceProjects(params.owner, viewer).catch(() => null) : null,
34− ]);
28+ const workspace = await identity.getWorkspace(params.owner);
3529 if (!workspace) {
3630 // A workspace's old name, after a rename: its pages are at the new one.
3731 await redirectIfRenamed(request, params.owner);
3832 throw notFound("workspace");
3933 }
4034 const role = roleIn(viewer, workspace.slug);
41− const projectCount = listed?.ok ? listed.value.length : null;
4235 // Opening one of your workspaces makes it the one you are in.
43− if (!role) return { workspace, role, welcome: false, projectCount };
36+ if (!role) return { workspace, role, welcome: false };
4437 const secure = new URL(request.url).protocol === "https:";
4538 const headers = new Headers({ "Set-Cookie": rememberWorkspace(workspace.slug, secure) });
4639 // Just joined with an invite: welcomed once (routes/invite.tsx).
4740 const welcome = welcomes(request.headers.get("cookie"), workspace.slug);
4841 if (welcome) headers.append("Set-Cookie", clearWelcome(secure));
49− return data({ workspace, role, welcome, projectCount }, { headers });
42+ return data({ workspace, role, welcome }, { headers });
5043 }
5144
5245 /** A workspace's own pages, each with its title and what it is for. */
120113 },
121114 };
122115
123−/**
124− * The workspace's tabs, as a project's are: a row under its header that
125− * scrolls sideways on a phone, without a scrollbar. Soon tabs say so with
126− * a dot.
127− */
128−function WorkspaceTabs({ tabs, current }: { tabs: WorkspaceTab[]; current: WorkspaceTabKey | null }) {
116+/** A workspace page's heading: its title and what it is for. */
117+function PageHeader({ title, about }: { title: string; about: string }) {
129118 return (
130− <nav aria-label="Workspace" className="relative -mb-px flex gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
131− {tabs.map((tab) => (
132− <NavLink
133− key={tab.key}
134− to={tab.to}
135− end={tab.key === "overview"}
136− prefetch="intent"
137− aria-current={current === tab.key ? "page" : undefined}
138− className={`flex shrink-0 items-center gap-1.5 border-b-2 px-3 pt-1 pb-2.5 text-sm transition-colors ${
139− current === tab.key
140− ? "border-accent font-medium text-fg"
141− : tab.soon
142− ? "border-transparent text-faint hover:text-muted"
143− : "border-transparent text-muted hover:text-fg"
144− }`}
145− >
146− {tab.label}
147− {tab.count != null && (
148− <span className="rounded-full bg-raised px-1.5 py-px text-xs tabular-nums text-muted">{tab.count}</span>
149− )}
150− {tab.soon && (
151− <>
152− <span aria-hidden="true" className="size-1.5 rounded-full bg-accent/70" />
153− <span className="sr-only">(soon)</span>
154− </>
155− )}
156− </NavLink>
157− ))}
158− </nav>
119+ <header className="mb-8 border-b border-line pb-6">
120+ <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
121+ <p className="mt-1.5 text-sm text-muted">{about}</p>
122+ </header>
159123 );
160124 }
161125
162126 export default function WorkspaceLayout({ loaderData }: Route.ComponentProps) {
163− const { workspace, role, welcome, projectCount } = loaderData;
127+ const { workspace, role, welcome } = loaderData;
164128 const { pathname } = useLocation();
165129 const going = useNavigation().location;
166− const tab = workspaceTab(pathname, workspace.slug);
167− // The sidebar finds the workspace's other pages, for everyone, so they
168− // need a title, not the workspace's whole header again.
130+ // The workspace's own page is its overview. Its other pages are found
131+ // from the sidebar, which lights the one you are on, and the top bar's
132+ // trail leads back here; each has a heading of its own.
133+ const here = workspacePage(pathname, workspace.slug);
134+ // On the way to another of its pages: that page's heading and shape
135+ // until it arrives.
136+ const next = going && going.pathname !== pathname ? workspacePage(going.pathname, workspace.slug) : null;
137+ const shown = next ?? here;
169138 const parts = (pathname.split("/-/")[1] ?? "").split("/").filter(Boolean);
170− const page = tab ? undefined : PAGES[parts[0] ?? ""];
171− // A page within one (a single package) has its own heading.
172− if (page && parts.length > 1) {
173− return (
174− <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8">
175− <Outlet />
176− </div>
177− );
178− }
179− if (page) {
139+ if (shown !== "overview") {
140+ const key = shown ?? parts[0] ?? "";
141+ const heading = PAGES[key];
142+ // A page within one (a single package or team) and a page that is
143+ // coming (Insights) have headings of their own.
144+ const titled = heading && (shown != null || parts.length === 1);
180145 return (
181146 <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8">
182− <header className="mb-8 border-b border-line pb-6">
183− <h1 className="text-2xl font-semibold tracking-tight">{page.title}</h1>
184− <p className="mt-1.5 text-sm text-muted">{page.about}</p>
185− </header>
186− <Outlet />
147+ {titled && <PageHeader title={heading.title} about={heading.about} />}
148+ {next ? <WorkspacePageSkeleton page={next} /> : <Outlet />}
187149 </div>
188150 );
189151 }
190− const tabs = workspaceTabs(workspace.slug, {
191− member: role != null,
192− owner: role === "owner",
193− projects: projectCount,
194− people: role ? workspace.memberCount : null,
195− });
196− // On the way to another of its tabs: that tab's shape until it arrives.
197− const next = going && going.pathname !== pathname ? workspaceTab(going.pathname, workspace.slug) : null;
198152 return (
199153 <>
200− {/* The workspace's own header band, with its tabs along the bottom. */}
154+ {/* The workspace's own header band, above its overview. */}
201155 <div className="border-b border-line bg-surface/60">
202− <div className="mx-auto max-w-6xl px-4 pt-8">
156+ <div className="mx-auto max-w-6xl px-4 py-8">
203157 {welcome && (
204158 <div className="mb-6">
205159 <WelcomeBanner title={`You're in ${workspace.name}`}>
218172 </div>
219173 <p className="font-mono text-sm text-muted">g1t.sh/{workspace.slug}</p>
220174 </div>
221− {role && (
175+ {role ? (
222176 <ButtonLink to={`/new?workspace=${workspace.slug}`}>
223177 <Plus size={15} />
224178 New project
225179 </ButtonLink>
180+ ) : (
181+ // A visitor's sidebar is not this workspace's: its packages
182+ // are a link here, as its projects are below.
183+ <ButtonLink to={`/${workspace.slug}/-/packages`} variant="quiet" prefetch="intent">
184+ <Package size={15} />
185+ Packages
186+ </ButtonLink>
226187 )}
227188 </div>
228189 {workspace.description && (
229190 <p className="mt-4 max-w-2xl text-sm text-muted">{workspace.description}</p>
230191 )}
231− <div className="mt-6">
232− <WorkspaceTabs tabs={tabs} current={next ?? tab} />
233− </div>
234192 </div>
235193 </div>
236194 <div className="mx-auto max-w-6xl px-4 py-8">
237− {/* What a tab is for, said once above it. */}
238− {tab && tab !== "overview" && !next && PAGES[tab] && <p className="mb-6 max-w-3xl text-sm text-muted">{PAGES[tab].about}</p>}
239− {next ? <WorkspaceTabSkeleton tab={next} /> : <Outlet />}
195+ {next ? <WorkspacePageSkeleton page="overview" /> : <Outlet />}
240196 </div>
241197 </>
242198 );
+3−3
77 import { pagePath } from "../../lib/workspace-nav";
88 import { SoonView } from "../repo/soon";
99
10−/** The tab is the last part of the address: `-/insights`. */
10+/** The page is the last part of the address: `-/insights`. */
1111 const tabOf = (pathname: string) => pagePath(pathname).split("/").pop() ?? "";
1212
1313 export function meta({ params, ...args }: Route.MetaArgs) {
1919 }
2020
2121 export function loader({ params, context, request }: Route.LoaderArgs) {
22− // For the workspace's members, as their tabs are.
22+ // For the workspace's members, as its People and Teams are.
2323 if (!roleIn(getViewer(context), params.owner)) throw data(null, { status: 404 });
2424 const item = roadmapItem(tabOf(new URL(request.url).pathname));
2525 if (!item || item.section !== "Workspace") throw data(null, { status: 404 });
2626 return { item };
2727 }
2828
29−/** A tab of the workspace's page that is coming: Insights. */
29+/** A page of the workspace's that is coming: Insights. */
3030 export default function WorkspaceTabSoon({ loaderData, params }: Route.ComponentProps) {
3131 return <SoonView item={loaderData.item} base={`/${params.owner}/-`} newProject={`/new?workspace=${params.owner}`} />;
3232 }