feat(nexus): add ModeBreadcrumb for layout overhaul (phase 8)
Uppercase slash-separated breadcrumb that derives from the current pathname per docs/specs/2026-04-11-nexus-layout-overhaul.md §4.2. Leaf segment in text-primary (volt), non-leaf segments in text-muted-foreground (silver). Pure function deriveBreadcrumbSegments is exported for unit testing and covers 16 route patterns plus the catch-all HOME fallback. The derivation intentionally collapses Phase 11's soon-to-be-demoted routes (issues/agents/routines/goals/approvals/costs/activity/inbox/ execution-workspaces) under the PROJECTS umbrella segment. When Phase 11 lands and those routes become /projects/:slug/<tab>, the derivation will naturally produce PROJECTS / PROJECT-SLUG without code changes. Part of Phase 8 of the Nexus layout overhaul (task 2 of 7). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9c1525a0f9
commit
bd4e7c5c5d
2 changed files with 209 additions and 0 deletions
104
ui/src/components/frame/ModeBreadcrumb.test.tsx
Normal file
104
ui/src/components/frame/ModeBreadcrumb.test.tsx
Normal file
|
|
@ -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<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 renderCrumb(pathname: string) {
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(
|
||||
<MemoryRouter initialEntries={[pathname]}>
|
||||
<ModeBreadcrumb />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
105
ui/src/components/frame/ModeBreadcrumb.tsx
Normal file
105
ui/src/components/frame/ModeBreadcrumb.tsx
Normal file
|
|
@ -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/<sub>` → `["STUDIO", "<SUB>"]`
|
||||
* - `/:prefix/convert*` → `["STUDIO", "CONVERT"]` (Phase 10 folds Convert into Studio)
|
||||
* - `/:prefix/projects` → `["PROJECTS"]`
|
||||
* - `/:prefix/projects/<slug>*` → `["PROJECTS", "<SLUG>"]`
|
||||
* - `/:prefix/(issues|agents|routines|goals|approvals|costs|activity|inbox)` → `["PROJECTS"]`
|
||||
* - `/:prefix/issues/<slug>` → `["PROJECTS", "<SLUG>"]`
|
||||
* - `/instance/settings` → `["SETTINGS"]`
|
||||
* - `/instance/settings/<sub>` (non-general) → `["SETTINGS", "<SUB>"]`
|
||||
* - 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/<rest>
|
||||
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 (
|
||||
<div
|
||||
data-testid="mode-breadcrumb"
|
||||
className="flex items-center gap-2 text-[14px] font-semibold uppercase tracking-[0.1em]"
|
||||
>
|
||||
{segments.map((segment, index) => {
|
||||
const isLeaf = index === segments.length - 1;
|
||||
return (
|
||||
<span key={index} className="flex items-center gap-2">
|
||||
{index > 0 && (
|
||||
<span
|
||||
data-testid="mode-breadcrumb-sep"
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
data-testid="mode-breadcrumb-segment"
|
||||
className={cn(isLeaf ? "text-primary" : "text-muted-foreground")}
|
||||
>
|
||||
{segment}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue