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:
parent
8527beca56
commit
2c3f4ff623
2 changed files with 359 additions and 0 deletions
190
ui/src/components/projects/BuilderTabStrip.test.tsx
Normal file
190
ui/src/components/projects/BuilderTabStrip.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
169
ui/src/components/projects/BuilderTabStrip.tsx
Normal file
169
ui/src/components/projects/BuilderTabStrip.tsx
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue