feat(nexus): add BuilderTabStrip component (phase 11)

Introduces the 7-tab Builder strip rendered under TopStrip on Project
Detail: OVERVIEW · ISSUES · AGENTS · GATES · COSTS · ACTIVITY · ORG.
Inter 600 14px uppercase, 0.1em tracking, silver default, volt text
+ 2px volt underline on active. ORG is hidden for single-agent
projects (spec §7.2.7).

Also exports resolveBuilderTab() and useActiveBuilderTab() for the
ProjectDetail integration. The link targets for agents/gates/costs/
activity/org route back to ProjectDetail via path matching until the
controller adds the new App.tsx routes post-Wave (see report).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 12:18:35 +00:00
parent 8527beca56
commit 2c3f4ff623
2 changed files with 359 additions and 0 deletions

View file

@ -0,0 +1,190 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "@/lib/router";
import {
BuilderTabStrip,
resolveBuilderTab,
BUILDER_TABS_FULL,
BUILDER_TABS_NO_ORG,
} from "./BuilderTabStrip";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
// Link calls useCompany() internally; stub CompanyContext like ModeBreadcrumb does.
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
companies: [],
selectedCompany: null,
selectedCompanyId: null,
setSelectedCompanyId: () => {},
selectionSource: null,
loading: false,
}),
}));
describe("resolveBuilderTab", () => {
it.each([
["/NEX/projects/abc", "abc", "overview"],
["/NEX/projects/abc/overview", "abc", "overview"],
["/NEX/projects/abc/issues", "abc", "issues"],
["/NEX/projects/abc/agents", "abc", "agents"],
["/NEX/projects/abc/gates", "abc", "gates"],
["/NEX/projects/abc/costs", "abc", "costs"],
["/NEX/projects/abc/activity", "abc", "activity"],
["/NEX/projects/abc/org", "abc", "org"],
["/projects/nexus-design/issues", "nexus-design", "issues"],
])("maps %s (ref=%s) to %s", (pathname, ref, expected) => {
expect(resolveBuilderTab(pathname as string, ref as string)).toBe(expected);
});
it("returns null for unrelated paths", () => {
expect(resolveBuilderTab("/NEX/assistant", "abc")).toBeNull();
});
it("returns null when the projectRef doesn't match", () => {
expect(resolveBuilderTab("/NEX/projects/other/issues", "abc")).toBeNull();
});
it("returns null for reserved sub-routes that aren't Builder tabs (configuration/budget)", () => {
// Phase 11 preserves existing Paperclip sub-routes but doesn't surface
// them in the Builder strip. They fall through to the existing Tabs
// dispatch inside ProjectDetail.
expect(resolveBuilderTab("/NEX/projects/abc/configuration", "abc")).toBeNull();
expect(resolveBuilderTab("/NEX/projects/abc/budget", "abc")).toBeNull();
expect(resolveBuilderTab("/NEX/projects/abc/workspaces", "abc")).toBeNull();
});
});
describe("BuilderTabStrip", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
});
afterEach(() => {
if (root) {
act(() => {
root!.unmount();
});
root = null;
}
if (container.parentNode) container.remove();
});
function renderStrip(props: Parameters<typeof BuilderTabStrip>[0], initialPath = "/") {
root = createRoot(container);
act(() => {
root!.render(
<MemoryRouter initialEntries={[initialPath]}>
<BuilderTabStrip {...props} />
</MemoryRouter>,
);
});
const tabs = Array.from(
container.querySelectorAll<HTMLElement>("[data-testid^='builder-tab-']"),
).filter((el) => el.dataset.testid !== "builder-tab-strip");
const active = tabs.find((el) => el.dataset.active === "true") ?? null;
return { tabs, active };
}
it("renders all 7 tabs when hasMultipleAgents is true", () => {
const { tabs } = renderStrip({
projectRef: "abc",
activeTab: "overview",
hasMultipleAgents: true,
});
expect(tabs.map((t) => t.textContent)).toEqual([
"OVERVIEW",
"ISSUES",
"AGENTS",
"GATES",
"COSTS",
"ACTIVITY",
"ORG",
]);
});
it("hides the ORG tab when hasMultipleAgents is false", () => {
const { tabs } = renderStrip({
projectRef: "abc",
activeTab: "overview",
hasMultipleAgents: false,
});
expect(tabs.map((t) => t.textContent)).toEqual([
"OVERVIEW",
"ISSUES",
"AGENTS",
"GATES",
"COSTS",
"ACTIVITY",
]);
expect(BUILDER_TABS_NO_ORG).not.toContain("org");
expect(BUILDER_TABS_FULL).toContain("org");
});
it("marks the active tab with data-active=true and volt styling", () => {
const { active } = renderStrip({
projectRef: "abc",
activeTab: "gates",
hasMultipleAgents: true,
});
expect(active?.dataset.testid).toBe("builder-tab-gates");
expect(active?.textContent).toBe("GATES");
expect(active?.className).toContain("text-primary");
expect(active?.className).toContain("after:bg-primary");
});
it("sets aria-current='page' on the active tab and omits it elsewhere", () => {
const { tabs, active } = renderStrip({
projectRef: "abc",
activeTab: "issues",
hasMultipleAgents: true,
});
expect(active?.getAttribute("aria-current")).toBe("page");
const inactive = tabs.filter((t) => t.dataset.active !== "true");
for (const el of inactive) {
expect(el.getAttribute("aria-current")).toBeNull();
}
});
it("uses silver default styling for inactive tabs", () => {
const { tabs } = renderStrip({
projectRef: "abc",
activeTab: "overview",
hasMultipleAgents: true,
});
const issues = tabs.find((t) => t.dataset.testid === "builder-tab-issues");
expect(issues?.className).toContain("text-muted-foreground");
});
it("builds Link hrefs pointing at /projects/<ref>/<tab>", () => {
const { tabs } = renderStrip({
projectRef: "nexus-design",
activeTab: "overview",
hasMultipleAgents: true,
});
const agents = tabs.find((t) => t.dataset.testid === "builder-tab-agents");
expect(agents?.getAttribute("href")).toContain("/projects/nexus-design/agents");
});
it("renders the strip nav with border-b and 24px gap", () => {
renderStrip({
projectRef: "abc",
activeTab: "overview",
hasMultipleAgents: true,
});
const nav = container.querySelector<HTMLElement>('[data-testid="builder-tab-strip"]');
expect(nav?.className).toContain("border-b");
expect(nav?.className).toContain("border-border");
expect(nav?.className).toContain("gap-6");
expect(nav?.className).toContain("pl-6");
});
});

View file

@ -0,0 +1,169 @@
// [nexus] Phase 11 — Project Detail "Builder mode" tab strip.
//
// Spec §7.2: a 40px horizontal strip under the TopStrip showing
// OVERVIEW · ISSUES · AGENTS · GATES · COSTS · ACTIVITY · ORG
//
// • Uppercase, Inter 600 14px, 0.1em tracking
// • Silver default, volt text + 2px volt bottom border on the active tab
// • 24px gap between tabs, 24px left padding
// • Border-b border-border on the strip itself
// • The ORG tab is hidden for single-agent projects (spec §7.2.7)
//
// The strip navigates via React Router <Link>. Some of the target routes
// (agents, gates, costs, activity, org) are not yet registered in
// App.tsx — see the Phase 11 report for the exact list the controller
// needs to add. React Router falls back to the base /projects/:projectId
// route which still renders <ProjectDetail /> in the interim.
import { Link, useLocation } from "@/lib/router";
import { cn } from "@/lib/utils";
export type ProjectBuilderTab =
| "overview"
| "issues"
| "agents"
| "gates"
| "costs"
| "activity"
| "org";
/** Full (multi-agent) tab order. */
export const BUILDER_TABS_FULL: readonly ProjectBuilderTab[] = [
"overview",
"issues",
"agents",
"gates",
"costs",
"activity",
"org",
];
/** Single-agent projects hide the ORG tab. */
export const BUILDER_TABS_NO_ORG: readonly ProjectBuilderTab[] = [
"overview",
"issues",
"agents",
"gates",
"costs",
"activity",
];
const TAB_LABELS: Record<ProjectBuilderTab, string> = {
overview: "OVERVIEW",
issues: "ISSUES",
agents: "AGENTS",
gates: "GATES",
costs: "COSTS",
activity: "ACTIVITY",
org: "ORG",
};
export interface BuilderTabStripProps {
/** Route ref for the project (usually the urlKey). */
projectRef: string;
/** The active tab. Pass "overview" when the URL has no tab suffix. */
activeTab: ProjectBuilderTab;
/**
* When false, the ORG tab is omitted from the strip. Single-agent
* projects don't need an org chart (spec §7.2.7).
*/
hasMultipleAgents: boolean;
}
/**
* Resolve the active Builder tab from a pathname. Returns `null` for
* pathnames that don't match /projects/:ref/<tab>, and `"overview"` for
* the bare /projects/:ref base route.
*/
export function resolveBuilderTab(
pathname: string,
projectRef: string,
): ProjectBuilderTab | null {
const segments = pathname.split("/").filter(Boolean);
const projectsIdx = segments.indexOf("projects");
if (projectsIdx === -1) return null;
if (segments[projectsIdx + 1] !== projectRef) return null;
const tab = segments[projectsIdx + 2];
if (!tab) return "overview";
if (
tab === "overview" ||
tab === "issues" ||
tab === "agents" ||
tab === "gates" ||
tab === "costs" ||
tab === "activity" ||
tab === "org"
) {
return tab;
}
// configuration / budget / workspaces / plugin tabs fall through — the
// BuilderTabStrip doesn't surface them and Project Detail preserves
// their existing behavior (Phase 11 plan §Preserving existing behavior).
return null;
}
function BuilderTabLink({
tab,
projectRef,
active,
}: {
tab: ProjectBuilderTab;
projectRef: string;
active: boolean;
}) {
const href = `/projects/${projectRef}/${tab}`;
return (
<Link
to={href}
data-testid={`builder-tab-${tab}`}
data-active={active ? "true" : "false"}
aria-current={active ? "page" : undefined}
className={cn(
// Base type: uppercase 14px Inter 600 tracking-[0.1em]
"relative inline-flex h-10 items-center text-[14px] font-semibold uppercase tracking-[0.1em]",
"no-underline transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
// Active: volt text + 2px volt bottom border
active
? "text-primary after:absolute after:inset-x-0 after:-bottom-px after:h-[2px] after:bg-primary after:content-['']"
: "text-muted-foreground hover:text-primary",
)}
>
{TAB_LABELS[tab]}
</Link>
);
}
export function BuilderTabStrip({
projectRef,
activeTab,
hasMultipleAgents,
}: BuilderTabStripProps) {
const tabs = hasMultipleAgents ? BUILDER_TABS_FULL : BUILDER_TABS_NO_ORG;
return (
<nav
data-testid="builder-tab-strip"
aria-label="Project builder tabs"
className="flex h-10 items-center gap-6 border-b border-border pl-6"
>
{tabs.map((tab) => (
<BuilderTabLink
key={tab}
tab={tab}
projectRef={projectRef}
active={tab === activeTab}
/>
))}
</nav>
);
}
/**
* Convenience hook: returns the active Builder tab for the current URL
* given a projectRef. Defaults to "overview" when the URL has no tab
* segment.
*/
export function useActiveBuilderTab(projectRef: string): ProjectBuilderTab | null {
const { pathname } = useLocation();
return resolveBuilderTab(pathname, projectRef);
}