diff --git a/ui/src/components/projects/BuilderTabStrip.test.tsx b/ui/src/components/projects/BuilderTabStrip.test.tsx new file mode 100644 index 00000000..ea6c9dbf --- /dev/null +++ b/ui/src/components/projects/BuilderTabStrip.test.tsx @@ -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 | 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[0], initialPath = "/") { + root = createRoot(container); + act(() => { + root!.render( + + + , + ); + }); + const tabs = Array.from( + container.querySelectorAll("[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//", () => { + 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('[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"); + }); +}); diff --git a/ui/src/components/projects/BuilderTabStrip.tsx b/ui/src/components/projects/BuilderTabStrip.tsx new file mode 100644 index 00000000..0be5a015 --- /dev/null +++ b/ui/src/components/projects/BuilderTabStrip.tsx @@ -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 . 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 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 = { + 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/, 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 ( + + {TAB_LABELS[tab]} + + ); +} + +export function BuilderTabStrip({ + projectRef, + activeTab, + hasMultipleAgents, +}: BuilderTabStripProps) { + const tabs = hasMultipleAgents ? BUILDER_TABS_FULL : BUILDER_TABS_NO_ORG; + + return ( + + ); +} + +/** + * 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); +}