diff --git a/ui/src/components/frame/ModeBreadcrumb.test.tsx b/ui/src/components/frame/ModeBreadcrumb.test.tsx new file mode 100644 index 00000000..735b731b --- /dev/null +++ b/ui/src/components/frame/ModeBreadcrumb.test.tsx @@ -0,0 +1,104 @@ +// @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 { ModeBreadcrumb, deriveBreadcrumbSegments } from "./ModeBreadcrumb"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +// Match the pattern from IconRail.test.tsx — @/lib/router Link calls +// useCompany() internally, so we stub CompanyContext for isolated testing. +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ + companies: [], + selectedCompany: null, + selectedCompanyId: null, + setSelectedCompanyId: () => {}, + selectionSource: null, + loading: false, + }), +})); + +describe("deriveBreadcrumbSegments", () => { + it.each([ + ["/NEX/assistant", ["ASSISTANT"]], + ["/NEX/assistant/conv-abc", ["ASSISTANT"]], + ["/NEX/content-studio", ["STUDIO"]], + ["/NEX/content-studio/diagrams", ["STUDIO", "DIAGRAMS"]], + ["/NEX/convert", ["STUDIO", "CONVERT"]], + ["/NEX/convert/pdf/docx", ["STUDIO", "CONVERT"]], + ["/NEX/projects", ["PROJECTS"]], + ["/NEX/projects/nexus-design", ["PROJECTS", "NEXUS-DESIGN"]], + ["/NEX/issues", ["PROJECTS"]], + ["/NEX/issues/NEX-42", ["PROJECTS", "NEX-42"]], + ["/NEX/agents", ["PROJECTS"]], + ["/NEX/routines", ["PROJECTS"]], + ["/instance/settings/general", ["SETTINGS"]], + ["/instance/settings/integrations", ["SETTINGS", "INTEGRATIONS"]], + ["/", ["HOME"]], + ["/unknown/path", ["HOME"]], + ])("maps %s to %o", (input, expected) => { + expect(deriveBreadcrumbSegments(input as string)).toEqual(expected); + }); +}); + +describe("ModeBreadcrumb (rendered)", () => { + 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 renderCrumb(pathname: string) { + root = createRoot(container); + act(() => { + root!.render( + + + , + ); + }); + return { + getRoot: () => container.querySelector("[data-testid='mode-breadcrumb']") as HTMLElement | null, + getSegments: () => + Array.from(container.querySelectorAll("[data-testid='mode-breadcrumb-segment']")).map( + (el) => el.textContent?.trim() ?? "", + ), + getSeparators: () => + container.querySelectorAll("[data-testid='mode-breadcrumb-sep']").length, + }; + } + + it("renders a single ASSISTANT segment and no separator", () => { + const { getSegments, getSeparators } = renderCrumb("/NEX/assistant"); + expect(getSegments()).toEqual(["ASSISTANT"]); + expect(getSeparators()).toBe(0); + }); + + it("renders two segments with one separator for /NEX/content-studio/diagrams", () => { + const { getSegments, getSeparators } = renderCrumb("/NEX/content-studio/diagrams"); + expect(getSegments()).toEqual(["STUDIO", "DIAGRAMS"]); + expect(getSeparators()).toBe(1); + }); + + it("renders PROJECTS / NEX-42 for /NEX/issues/NEX-42", () => { + const { getSegments } = renderCrumb("/NEX/issues/NEX-42"); + expect(getSegments()).toEqual(["PROJECTS", "NEX-42"]); + }); +}); diff --git a/ui/src/components/frame/ModeBreadcrumb.tsx b/ui/src/components/frame/ModeBreadcrumb.tsx new file mode 100644 index 00000000..2112b992 --- /dev/null +++ b/ui/src/components/frame/ModeBreadcrumb.tsx @@ -0,0 +1,105 @@ +import { useLocation } from "@/lib/router"; +import { cn } from "@/lib/utils"; + +/** + * Derive breadcrumb segments from a pathname. + * + * Exposed for unit testing — the component itself just consumes this. + * + * Mapping rules: + * - `/:prefix/assistant*` → `["ASSISTANT"]` (any suffix collapses) + * - `/:prefix/content-studio` → `["STUDIO"]` + * - `/:prefix/content-studio/` → `["STUDIO", ""]` + * - `/:prefix/convert*` → `["STUDIO", "CONVERT"]` (Phase 10 folds Convert into Studio) + * - `/:prefix/projects` → `["PROJECTS"]` + * - `/:prefix/projects/*` → `["PROJECTS", ""]` + * - `/:prefix/(issues|agents|routines|goals|approvals|costs|activity|inbox)` → `["PROJECTS"]` + * - `/:prefix/issues/` → `["PROJECTS", ""]` + * - `/instance/settings` → `["SETTINGS"]` + * - `/instance/settings/` (non-general) → `["SETTINGS", ""]` + * - everything else → `["HOME"]` + */ +export function deriveBreadcrumbSegments(pathname: string): string[] { + // /instance/settings/... + if (pathname.startsWith("/instance/settings")) { + const rest = pathname.replace(/^\/instance\/settings\/?/, ""); + if (!rest) return ["SETTINGS"]; + const leaf = rest.split("/")[0] ?? ""; + if (!leaf || leaf === "general") return ["SETTINGS"]; + return ["SETTINGS", leaf.toUpperCase()]; + } + + // Company-prefixed routes: /NEX/ + const match = pathname.match(/^\/[^/]+\/(.*)$/); + if (!match) return ["HOME"]; + const rest = match[1] ?? ""; + if (!rest) return ["HOME"]; + + const segments = rest.split("/").filter(Boolean); + const head = segments[0]; + const next = segments[1]; + + switch (head) { + case "assistant": + return ["ASSISTANT"]; + + case "content-studio": + case "studio": + return next ? ["STUDIO", next.toUpperCase()] : ["STUDIO"]; + + case "convert": + return ["STUDIO", "CONVERT"]; + + case "projects": + return next ? ["PROJECTS", next.toUpperCase()] : ["PROJECTS"]; + + case "issues": + case "agents": + case "routines": + case "goals": + case "approvals": + case "costs": + case "activity": + case "inbox": + case "execution-workspaces": + return next ? ["PROJECTS", next.toUpperCase()] : ["PROJECTS"]; + + default: + return ["HOME"]; + } +} + +export function ModeBreadcrumb() { + const { pathname } = useLocation(); + const segments = deriveBreadcrumbSegments(pathname); + + return ( +
+ {segments.map((segment, index) => { + const isLeaf = index === segments.length - 1; + return ( + + {index > 0 && ( + + )} + + {segment} + + + ); + })} +
+ ); +}