feat(dashboard): Usage back to header; Skills/Memory after Mailbox; tests updated
- Usage (Activity) removed from the right dock and rendered as a non-mobile header button (left of the right-sidebar toggle) that opens the UsageIndicator as a header-anchored modal. - Left sidebar: Skills and Memory moved to immediately after Mailbox. - Updated overflowViewRegistry / Header / tablet-header / LeftSidebarNav tests to the new behavior (all green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -176,9 +176,19 @@ describe("tablet header controls", () => {
|
||||
expect(screen.queryByTitle("Automation")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render usage button inline on tablet", () => {
|
||||
renderTabletHeader({ onOpenUsage: noop });
|
||||
expect(screen.queryByTitle("View usage")).toBeNull();
|
||||
it("renders the header usage button to the left of the right-dock toggle on tablet", () => {
|
||||
const onOpenUsage = vi.fn();
|
||||
renderTabletHeader({ onOpenUsage, rightDockAvailable: true, onToggleRightDock: noop });
|
||||
|
||||
const usageBtn = screen.getByTestId("header-usage-btn");
|
||||
expect(usageBtn.getAttribute("title")).toBe("View usage");
|
||||
// Sits immediately to the left of the right-dock toggle.
|
||||
expect(usageBtn.nextElementSibling).toBe(screen.getByTestId("header-right-dock-toggle"));
|
||||
|
||||
const mockRect = { x: 0, y: 0, top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, toJSON: () => ({}) } as DOMRect;
|
||||
(usageBtn as HTMLButtonElement).getBoundingClientRect = vi.fn(() => mockRect);
|
||||
fireEvent.click(usageBtn);
|
||||
expect(onOpenUsage).toHaveBeenCalledWith(mockRect);
|
||||
});
|
||||
|
||||
it("does not render activity log button inline on tablet", () => {
|
||||
@@ -201,115 +211,79 @@ describe("tablet header controls", () => {
|
||||
expect(screen.queryByTitle("Workflows")).toBeNull();
|
||||
});
|
||||
|
||||
// ── Overflow menu on tablet ────────────────────────────────────
|
||||
// ── Right-sidebar toggle replaces the three-dots overflow on tablet ─────
|
||||
//
|
||||
// FNXC:Navigation 2026-06-22-01:44:
|
||||
// The tablet three-dots compact overflow menu was retired: the mobile-only
|
||||
// overflow trigger gate (isMobile && !hideFullNav) means tablet no longer
|
||||
// renders "More header actions" or any header overflow menu. Instead, the
|
||||
// non-mobile right-sidebar show/hide toggle (header-right-dock-toggle) owns
|
||||
// that header slot. Tablet tool actions live in the right dock, not the header.
|
||||
|
||||
it("renders overflow menu trigger on tablet", () => {
|
||||
it("does not render the three-dots overflow trigger on tablet", () => {
|
||||
renderTabletHeader();
|
||||
expect(screen.getByTitle("More header actions")).toBeDefined();
|
||||
expect(screen.queryByTitle("More header actions")).toBeNull();
|
||||
expect(document.querySelector(".compact-overflow-trigger")).toBeNull();
|
||||
});
|
||||
|
||||
it("overflow menu contains settings on tablet", () => {
|
||||
renderTabletHeader();
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByText("Settings")).toBeDefined();
|
||||
it("renders the right-sidebar toggle on tablet when the dock is available", () => {
|
||||
renderTabletHeader({ rightDockAvailable: true, onToggleRightDock: noop });
|
||||
expect(screen.getByTestId("header-right-dock-toggle")).toBeDefined();
|
||||
expect(screen.queryByTitle("More header actions")).toBeNull();
|
||||
});
|
||||
|
||||
it("overflow menu omits planning on tablet", () => {
|
||||
it("toggles the right sidebar from the tablet header toggle", () => {
|
||||
const onToggleRightDock = vi.fn();
|
||||
renderTabletHeader({ rightDockAvailable: true, onToggleRightDock });
|
||||
fireEvent.click(screen.getByTestId("header-right-dock-toggle"));
|
||||
expect(onToggleRightDock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reflects the right-sidebar open state on the tablet toggle", () => {
|
||||
renderTabletHeader({ rightDockAvailable: true, rightDockOpen: true, onToggleRightDock: noop });
|
||||
const toggle = screen.getByTestId("header-right-dock-toggle");
|
||||
expect(toggle.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(toggle.getAttribute("title")).toBe("Hide right sidebar");
|
||||
});
|
||||
|
||||
it("does not render the right-sidebar toggle on tablet when the dock is unavailable", () => {
|
||||
renderTabletHeader({ onToggleRightDock: noop });
|
||||
expect(screen.queryByTestId("header-right-dock-toggle")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render planning affordances in the tablet header", () => {
|
||||
renderTabletHeader();
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.queryByTestId("overflow-planning-btn")).toBeNull();
|
||||
expect(screen.queryByTitle("Create a task with AI planning")).toBeNull();
|
||||
});
|
||||
|
||||
it("overflow menu contains GitHub import on tablet", () => {
|
||||
it("does not render GitHub import inline or in any overflow on tablet", () => {
|
||||
renderTabletHeader();
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByText("Import from GitHub")).toBeDefined();
|
||||
expect(screen.queryByText("Import from GitHub")).toBeNull();
|
||||
});
|
||||
|
||||
it("overflow menu omits terminal launcher and scripts on tablet", () => {
|
||||
it("does not render terminal launcher and scripts affordances on tablet", () => {
|
||||
renderTabletHeader({ onToggleTerminal: noop, onOpenScripts: noop });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.queryByTestId("overflow-terminal-primary-btn")).toBeNull();
|
||||
expect(screen.queryByTestId("overflow-terminal-submenu-toggle")).toBeNull();
|
||||
expect(screen.queryByTestId("overflow-scripts-manage")).toBeNull();
|
||||
});
|
||||
|
||||
it("overflow menu contains automation on tablet", () => {
|
||||
renderTabletHeader({ onOpenSchedules: noop });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByText("Automation")).toBeDefined();
|
||||
});
|
||||
|
||||
it("overflow menu contains usage on tablet when provided", () => {
|
||||
renderTabletHeader({ onOpenUsage: noop });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-usage-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("overflow menu contains activity log on tablet when provided", () => {
|
||||
renderTabletHeader({ onOpenActivityLog: noop });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-activity-log-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("overflow menu contains files on tablet when provided", () => {
|
||||
renderTabletHeader({ onOpenFiles: noop });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-files-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("overflow menu contains git manager on tablet when provided", () => {
|
||||
renderTabletHeader({ onOpenGitManager: noop });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-git-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("overflow menu contains workflows on tablet when provided", () => {
|
||||
renderTabletHeader({ onOpenWorkflowEditor: noop });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-workflow-steps-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Overflow menu callbacks work on tablet ─────────────────────
|
||||
|
||||
it("calls onOpenSettings from overflow menu on tablet", () => {
|
||||
const onOpenSettings = vi.fn();
|
||||
renderTabletHeader({ onOpenSettings });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
fireEvent.click(screen.getByText("Settings"));
|
||||
expect(onOpenSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onOpenUsage from overflow menu on tablet", () => {
|
||||
const onOpenUsage = vi.fn();
|
||||
renderTabletHeader({ onOpenUsage });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
fireEvent.click(screen.getByTestId("overflow-usage-btn"));
|
||||
expect(onOpenUsage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes overflow menu after selecting an action on tablet", () => {
|
||||
renderTabletHeader();
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByRole("menu")).toBeDefined();
|
||||
fireEvent.click(screen.getByText("Settings"));
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes overflow menu on outside click on tablet", () => {
|
||||
renderTabletHeader();
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByRole("menu")).toBeDefined();
|
||||
fireEvent.mouseDown(document.body);
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes overflow menu on Escape key on tablet", () => {
|
||||
renderTabletHeader();
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByRole("menu")).toBeDefined();
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
it("does not render automation, usage, activity log, files, git, or workflow header items on tablet", () => {
|
||||
renderTabletHeader({
|
||||
onOpenSchedules: noop,
|
||||
onOpenUsage: noop,
|
||||
onOpenActivityLog: noop,
|
||||
onOpenFiles: noop,
|
||||
onOpenGitManager: noop,
|
||||
onOpenWorkflowEditor: noop,
|
||||
});
|
||||
expect(screen.queryByText("Automation")).toBeNull();
|
||||
expect(screen.queryByTestId("overflow-usage-btn")).toBeNull();
|
||||
expect(screen.queryByTestId("overflow-activity-log-btn")).toBeNull();
|
||||
expect(screen.queryByTestId("overflow-files-btn")).toBeNull();
|
||||
expect(screen.queryByTestId("overflow-git-btn")).toBeNull();
|
||||
expect(screen.queryByTestId("overflow-workflow-steps-btn")).toBeNull();
|
||||
});
|
||||
|
||||
// ── Search on tablet ───────────────────────────────────────────
|
||||
@@ -393,7 +367,7 @@ describe("tablet header controls", () => {
|
||||
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show projects entry in overflow menu on tablet", () => {
|
||||
it("does not show a projects overflow entry on tablet (no header overflow exists)", () => {
|
||||
const projects = [
|
||||
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
|
||||
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
|
||||
@@ -404,7 +378,7 @@ describe("tablet header controls", () => {
|
||||
currentProject: projects[0],
|
||||
onViewAllProjects,
|
||||
});
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.queryByTitle("More header actions")).toBeNull();
|
||||
expect(screen.queryByTestId("overflow-project-selector-btn")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -466,9 +440,9 @@ describe("tablet header controls", () => {
|
||||
// ── Terminal launcher relocation regression tests ─────────────
|
||||
|
||||
describe("terminal launcher relocation on tablet", () => {
|
||||
it("keeps terminal launcher affordances out of the tablet header overflow", () => {
|
||||
it("keeps terminal launcher affordances out of the tablet header (no overflow exists)", () => {
|
||||
renderTabletHeader({ onToggleTerminal: noop, onOpenScripts: noop, projectId: "test-project" });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.queryByTitle("More header actions")).toBeNull();
|
||||
expect(screen.queryByTestId("overflow-terminal-primary-btn")).toBeNull();
|
||||
expect(screen.queryByTestId("overflow-terminal-submenu-toggle")).toBeNull();
|
||||
expect(screen.queryByTestId("overflow-script-item-build")).toBeNull();
|
||||
@@ -476,10 +450,15 @@ describe("tablet header controls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Settings is the last overflow menu item ────────────────────
|
||||
// ── No header overflow menu remains on tablet ──────────────────
|
||||
//
|
||||
// FNXC:Navigation 2026-06-22-01:44:
|
||||
// The Settings-last overflow ordering invariant no longer applies on tablet
|
||||
// because the three-dots overflow menu is mobile-only. Tablet renders no
|
||||
// .mobile-overflow-menu and no menu role; Settings lives in the right dock.
|
||||
|
||||
describe("overflow menu ordering on tablet", () => {
|
||||
it("Settings is the last item in the tablet overflow menu when all optional items are present", () => {
|
||||
describe("no overflow menu on tablet", () => {
|
||||
it("renders no header overflow menu on tablet even when all optional items are provided", () => {
|
||||
const { container } = renderTabletHeader({
|
||||
onOpenUsage: noop,
|
||||
onOpenActivityLog: noop,
|
||||
@@ -488,26 +467,15 @@ describe("tablet header controls", () => {
|
||||
onOpenGitManager: noop,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
|
||||
// Get all menu items inside the overflow menu
|
||||
const menu = container.querySelector(".mobile-overflow-menu")!;
|
||||
const menuItems = Array.from(menu.querySelectorAll<HTMLButtonElement>("button.mobile-overflow-item"));
|
||||
|
||||
// The last menu item should be Settings
|
||||
const lastItem = menuItems[menuItems.length - 1];
|
||||
expect(lastItem.textContent).toBe("Settings");
|
||||
expect(container.querySelector(".mobile-overflow-menu")).toBeNull();
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
expect(screen.queryByTitle("More header actions")).toBeNull();
|
||||
});
|
||||
|
||||
it("Settings is the last item in the tablet overflow menu when optional items are absent", () => {
|
||||
renderTabletHeader();
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
|
||||
const menu = screen.getByRole("menu");
|
||||
const menuItems = Array.from(menu.querySelectorAll<HTMLButtonElement>("button[role='menuitem']"));
|
||||
|
||||
const lastItem = menuItems[menuItems.length - 1];
|
||||
expect(lastItem.textContent).toBe("Settings");
|
||||
it("renders no header overflow menu on tablet when optional items are absent", () => {
|
||||
const { container } = renderTabletHeader();
|
||||
expect(container.querySelector(".mobile-overflow-menu")).toBeNull();
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -974,6 +974,22 @@ export function Header({
|
||||
{/* Plugin UI slot for header actions */}
|
||||
<PluginSlot slotId="header-action" projectId={projectId} />
|
||||
|
||||
{/*
|
||||
FNXC:Navigation 2026-06-22-00:50:
|
||||
Usage (Activity) lives in the top header to the left of the right-sidebar toggle and opens the UsageIndicator as a header-anchored modal (not inline in the dock). Non-mobile only; mobile keeps its own usage button in the bottom-nav layout.
|
||||
*/}
|
||||
{!isMobile && onOpenUsage && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={(event) => onOpenUsage(event.currentTarget.getBoundingClientRect())}
|
||||
title={t("header.viewUsage", "View usage")}
|
||||
aria-label={t("header.viewUsage", "View usage")}
|
||||
data-testid="header-usage-btn"
|
||||
>
|
||||
<Activity size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/*
|
||||
FNXC:Navigation 2026-06-22-00:00:
|
||||
Non-mobile surfaces (desktop + tablet) get a single right-sidebar show/hide toggle that owns the right dock visibility. It replaces the tablet three-dots overflow; the dock is fully hidden when closed and reopened from here. Mobile is intentionally excluded — it keeps its existing overflow menu untouched and has no right dock.
|
||||
|
||||
@@ -340,6 +340,16 @@ export function LeftSidebarNav({
|
||||
dot: view !== "mailbox" && mailboxPendingApprovalCount > 0 ? "pending" : view !== "mailbox" && mailboxUnreadCount > 0 ? "online" : undefined,
|
||||
onSelect: () => onChangeView("mailbox"),
|
||||
},
|
||||
/*
|
||||
FNXC:Navigation 2026-06-22-00:50:
|
||||
Skills and Memory sit directly after Mailbox (still flag-gated by showSkillsTab / memoryView).
|
||||
*/
|
||||
...(showSkillsTab
|
||||
? [{ id: "skills", label: t("header.skillsView", "Skills"), view: "skills" as TaskView, isActive: view === "skills", icon: Zap, testId: "sidebar-nav-skills", onSelect: () => onChangeView("skills") }]
|
||||
: []),
|
||||
...(experimentalFeatures?.memoryView
|
||||
? [{ id: "memory", label: t("header.memoryView", "Memory"), view: "memory" as TaskView, isActive: view === "memory", icon: Brain, testId: "sidebar-nav-memory", onSelect: () => onChangeView("memory") }]
|
||||
: []),
|
||||
{
|
||||
id: "planning",
|
||||
/*
|
||||
@@ -416,12 +426,6 @@ export function LeftSidebarNav({
|
||||
...(experimentalFeatures?.researchView
|
||||
? [{ id: "research", label: t("header.researchView", "Research"), view: "research" as TaskView, isActive: view === "research", icon: Search, testId: "sidebar-nav-research", onSelect: () => onChangeView("research") }]
|
||||
: []),
|
||||
...(showSkillsTab
|
||||
? [{ id: "skills", label: t("header.skillsView", "Skills"), view: "skills" as TaskView, isActive: view === "skills", icon: Zap, testId: "sidebar-nav-skills", onSelect: () => onChangeView("skills") }]
|
||||
: []),
|
||||
...(experimentalFeatures?.memoryView
|
||||
? [{ id: "memory", label: t("header.memoryView", "Memory"), view: "memory" as TaskView, isActive: view === "memory", icon: Brain, testId: "sidebar-nav-memory", onSelect: () => onChangeView("memory") }]
|
||||
: []),
|
||||
...(experimentalFeatures?.evalsView
|
||||
? [{ id: "evals", label: t("header.evalsView", "Evals"), view: "evals" as TaskView, isActive: view === "evals", icon: Target, testId: "sidebar-nav-evals", onSelect: () => onChangeView("evals") }]
|
||||
: []),
|
||||
|
||||
@@ -580,10 +580,24 @@ describe("Header", () => {
|
||||
expect(screen.queryByTitle("View usage")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render usage button inline on desktop when onOpenUsage is provided", () => {
|
||||
renderHeader({ onOpenUsage: vi.fn() }, "desktop");
|
||||
expect(screen.queryByTitle("View usage")).toBeNull();
|
||||
it("renders the header usage button to the left of the right-dock toggle on desktop when onOpenUsage is provided", () => {
|
||||
renderHeader({ onOpenUsage: vi.fn(), rightDockAvailable: true, onToggleRightDock: noop }, "desktop");
|
||||
const usageBtn = screen.getByTestId("header-usage-btn");
|
||||
expect(usageBtn.getAttribute("title")).toBe("View usage");
|
||||
// Retired legacy toolbar testid stays gone.
|
||||
expect(screen.queryByTestId("desktop-header-usage-btn")).toBeNull();
|
||||
// Sits immediately to the left of the right-dock toggle.
|
||||
expect(usageBtn.nextElementSibling).toBe(screen.getByTestId("header-right-dock-toggle"));
|
||||
});
|
||||
|
||||
it("fires onOpenUsage with button bounds from the desktop header usage button", () => {
|
||||
const onOpenUsage = vi.fn();
|
||||
renderHeader({ onOpenUsage }, "desktop");
|
||||
const usageBtn = screen.getByTestId("header-usage-btn") as HTMLButtonElement;
|
||||
const mockRect = { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}) } as DOMRect;
|
||||
usageBtn.getBoundingClientRect = vi.fn(() => mockRect);
|
||||
fireEvent.click(usageBtn);
|
||||
expect(onOpenUsage).toHaveBeenCalledWith(mockRect);
|
||||
});
|
||||
|
||||
it("does not render usage button inline on mobile when onOpenUsage is provided", () => {
|
||||
@@ -1399,7 +1413,11 @@ describe("Header", () => {
|
||||
});
|
||||
|
||||
describe("action ordering", () => {
|
||||
it("Settings is the last inline action on desktop after engine controls moved to the footer", () => {
|
||||
it("places only the Usage button after Settings on desktop after engine controls moved to the footer", () => {
|
||||
/*
|
||||
FNXC:Navigation 2026-06-22-12:00:
|
||||
Usage moved back to the top header (left of the right-dock toggle), so it now renders after Settings in the inline header actions. Settings is the last inline action ONLY among the primary controls; the trailing Usage button (and the right-dock toggle when available) intentionally follow it.
|
||||
*/
|
||||
const { container } = renderHeader({
|
||||
onOpenUsage: noop,
|
||||
onOpenActivityLog: noop,
|
||||
@@ -1424,7 +1442,8 @@ describe("Header", () => {
|
||||
expect(settingsIdx).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const itemsAfterSettings = inlineItems.slice(settingsIdx + 1);
|
||||
expect(itemsAfterSettings).toHaveLength(0);
|
||||
// Only the relocated Usage button trails Settings (no right-dock toggle without rightDockAvailable).
|
||||
expect(itemsAfterSettings.map((el) => el.getAttribute("data-testid"))).toEqual(["header-usage-btn"]);
|
||||
});
|
||||
|
||||
it("Settings is the last item in the mobile overflow menu", () => {
|
||||
|
||||
@@ -208,20 +208,22 @@ describe("LeftSidebarNav", () => {
|
||||
for (const testId of [
|
||||
"sidebar-nav-board",
|
||||
"sidebar-nav-list",
|
||||
"sidebar-nav-agents",
|
||||
"sidebar-nav-command-center",
|
||||
"sidebar-nav-agents",
|
||||
"sidebar-nav-chat",
|
||||
"sidebar-nav-mailbox",
|
||||
"sidebar-nav-planning",
|
||||
"sidebar-nav-missions",
|
||||
"sidebar-nav-chat",
|
||||
"sidebar-nav-documents",
|
||||
"sidebar-nav-mailbox",
|
||||
"sidebar-nav-evals",
|
||||
"sidebar-nav-goals",
|
||||
"sidebar-nav-research",
|
||||
"sidebar-nav-automations",
|
||||
"sidebar-nav-import-tasks",
|
||||
"sidebar-nav-workflows",
|
||||
"sidebar-nav-insights",
|
||||
"sidebar-nav-research",
|
||||
"sidebar-nav-skills",
|
||||
"sidebar-nav-memory",
|
||||
"sidebar-nav-devserver",
|
||||
"sidebar-nav-evals",
|
||||
"sidebar-nav-plugin-fusion-plugin-primary-primary-view",
|
||||
"sidebar-nav-plugin-fusion-plugin-overflow-overflow-view",
|
||||
"sidebar-nav-settings",
|
||||
@@ -231,11 +233,68 @@ describe("LeftSidebarNav", () => {
|
||||
|
||||
expect(screen.getByTestId("sidebar-nav-documents")).toHaveTextContent("Artifacts");
|
||||
expect(screen.getByTestId("sidebar-nav-planning")).toHaveTextContent("Planning");
|
||||
expect(screen.getByTestId("sidebar-nav-import-tasks")).toHaveTextContent("Import Tasks");
|
||||
expect(screen.queryByTestId("sidebar-nav-stash-recovery")).toBeNull();
|
||||
|
||||
/*
|
||||
FNXC:Navigation 2026-06-22-12:00:
|
||||
Import Tasks renders a custom GitHub octocat SVG (lucide-react has no Github export), not a lucide icon. The octocat path is the discriminator.
|
||||
*/
|
||||
const importIconSvg = screen.getByTestId("sidebar-nav-import-tasks").querySelector("svg");
|
||||
expect(importIconSvg).not.toBeNull();
|
||||
expect(importIconSvg?.getAttribute("viewBox")).toBe("0 0 24 24");
|
||||
expect(importIconSvg?.querySelector("path")?.getAttribute("d")).toContain("M12 2C6.477 2 2 6.484 2 12.017");
|
||||
|
||||
/*
|
||||
FNXC:Navigation 2026-06-22-12:00:
|
||||
Dev Server moved to the right dock; the sidebar no longer renders a devserver entry even when the devServerView flag is on.
|
||||
*/
|
||||
expect(screen.queryByTestId("sidebar-nav-devserver")).toBeNull();
|
||||
|
||||
const primaryNav = screen.getByRole("navigation", { name: "Primary navigation" });
|
||||
|
||||
/*
|
||||
FNXC:Navigation 2026-06-22-12:00:
|
||||
The sidebar collapsed its two placement sections into ONE explicitly-ordered list; the `--secondary` section is gone.
|
||||
*/
|
||||
expect(primaryNav.querySelectorAll(".left-sidebar-nav__section")).toHaveLength(1);
|
||||
expect(primaryNav.querySelector(".left-sidebar-nav__section--secondary")).toBeNull();
|
||||
|
||||
/*
|
||||
FNXC:Navigation 2026-06-22-12:00:
|
||||
Assert the intentional single-list order (top to bottom) for the entries present under the default render flags.
|
||||
command-center precedes agents; skills/memory (flag-gated) sit immediately after mailbox and before planning; documents (Artifacts) follows missions; automations -> import-tasks -> workflows are contiguous after compound/goals.
|
||||
*/
|
||||
const primaryButtons = within(primaryNav).getAllByRole("button");
|
||||
expect(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-planning"))).toBe(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-command-center")) + 1);
|
||||
const orderedTestIds = [
|
||||
"sidebar-nav-board",
|
||||
"sidebar-nav-list",
|
||||
"sidebar-nav-command-center",
|
||||
"sidebar-nav-agents",
|
||||
"sidebar-nav-chat",
|
||||
"sidebar-nav-mailbox",
|
||||
"sidebar-nav-skills",
|
||||
"sidebar-nav-memory",
|
||||
"sidebar-nav-planning",
|
||||
"sidebar-nav-missions",
|
||||
"sidebar-nav-documents",
|
||||
"sidebar-nav-goals",
|
||||
"sidebar-nav-automations",
|
||||
"sidebar-nav-import-tasks",
|
||||
"sidebar-nav-workflows",
|
||||
"sidebar-nav-insights",
|
||||
"sidebar-nav-research",
|
||||
"sidebar-nav-evals",
|
||||
];
|
||||
const orderedIndices = orderedTestIds.map((testId) => primaryButtons.indexOf(screen.getByTestId(testId)));
|
||||
expect(orderedIndices).toEqual([...orderedIndices].sort((a, b) => a - b));
|
||||
expect(orderedIndices.every((index) => index >= 0)).toBe(true);
|
||||
expect(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-command-center"))).toBeLessThan(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-agents")));
|
||||
expect(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-documents"))).toBe(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-missions")) + 1);
|
||||
// Skills and Memory sit immediately after Mailbox and before Planning.
|
||||
expect(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-skills"))).toBe(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-mailbox")) + 1);
|
||||
expect(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-memory"))).toBe(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-skills")) + 1);
|
||||
expect(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-planning"))).toBe(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-memory")) + 1);
|
||||
|
||||
const sidebar = screen.getByTestId("left-sidebar-nav");
|
||||
const footer = screen.getByTestId("sidebar-nav-settings").closest(".left-sidebar-nav__footer");
|
||||
@@ -292,9 +351,17 @@ describe("LeftSidebarNav", () => {
|
||||
expect(screen.queryByTestId("sidebar-nav-memory")).toBeNull();
|
||||
expect(screen.queryByTestId("sidebar-nav-evals")).toBeNull();
|
||||
expect(screen.queryByTestId("sidebar-nav-goals")).toBeNull();
|
||||
expect(screen.queryByTestId("sidebar-nav-devserver")).toBeNull();
|
||||
expect(screen.queryByTestId("sidebar-nav-plugin-fusion-plugin-primary-primary-view")).toBeNull();
|
||||
|
||||
/*
|
||||
FNXC:Navigation 2026-06-22-12:00:
|
||||
Unconditional left-sidebar destinations survive empty flags/props: automations, import-tasks (Import Tasks), and workflows are always present; devserver never renders here (right dock).
|
||||
*/
|
||||
expect(screen.getByTestId("sidebar-nav-automations")).toBeDefined();
|
||||
expect(screen.getByTestId("sidebar-nav-import-tasks")).toBeDefined();
|
||||
expect(screen.getByTestId("sidebar-nav-workflows")).toBeDefined();
|
||||
expect(screen.queryByTestId("sidebar-nav-devserver")).toBeNull();
|
||||
|
||||
const sidebar = screen.getByTestId("left-sidebar-nav");
|
||||
expect(screen.getByTestId("sidebar-nav-settings").closest(".left-sidebar-nav__footer")).not.toBeNull();
|
||||
expect(within(sidebar).getAllByRole("button").at(-1)).toBe(screen.getByTestId("sidebar-nav-settings"));
|
||||
|
||||
@@ -3,42 +3,63 @@ import { getVisibleOverflowViewEntries, STATIC_OVERFLOW_VIEW_ENTRIES } from "../
|
||||
import type { PluginDashboardViewEntry } from "../../api";
|
||||
|
||||
describe("overflowViewRegistry", () => {
|
||||
it("exposes exactly the six static right-dock tool destinations", () => {
|
||||
const entries = getVisibleOverflowViewEntries();
|
||||
it("exposes the static right-dock tool destinations in order", () => {
|
||||
// devserver/todos are gated by their isVisible flags; enable them to see the full static set.
|
||||
const entries = getVisibleOverflowViewEntries({
|
||||
experimentalFeatures: { devServerView: true },
|
||||
todosEnabled: true,
|
||||
});
|
||||
const keys = entries.map((entry) => entry.key);
|
||||
|
||||
expect(keys).toEqual(["usage", "activity-log", "github-import", "git-manager", "files", "automation"]);
|
||||
expect(entries.map((entry) => entry.label)).toEqual([
|
||||
"Activity",
|
||||
"Activity Log",
|
||||
"Import from GitHub",
|
||||
"Git Manager",
|
||||
"Files",
|
||||
"Automation",
|
||||
]);
|
||||
expect(entries.filter((entry) => entry.render).map((entry) => entry.key)).toEqual(["files"]);
|
||||
expect(entries.filter((entry) => entry.onActivate).map((entry) => entry.key)).toEqual([
|
||||
"usage",
|
||||
expect(keys).toEqual([
|
||||
"files",
|
||||
"activity-log",
|
||||
"github-import",
|
||||
"git-manager",
|
||||
"automation",
|
||||
"devserver",
|
||||
"secrets",
|
||||
"todos",
|
||||
"pull-requests",
|
||||
]);
|
||||
expect(entries.map((entry) => entry.label)).toEqual([
|
||||
"Files",
|
||||
"Activity Log",
|
||||
"Git Manager",
|
||||
"Dev Server",
|
||||
"Secrets",
|
||||
"Todos",
|
||||
"Pull Requests",
|
||||
]);
|
||||
// Every static dock destination renders inline; none use onActivate launcher actions anymore.
|
||||
expect(entries.filter((entry) => entry.render).map((entry) => entry.key)).toEqual(keys);
|
||||
expect(entries.filter((entry) => entry.onActivate)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not expose left-sidebar content views in the right-dock registry", () => {
|
||||
it("hides flag-gated dock tools when their flags are off", () => {
|
||||
const keys = getVisibleOverflowViewEntries().map((entry) => entry.key);
|
||||
|
||||
// devserver requires experimentalFeatures.devServerView; todos requires todosEnabled.
|
||||
expect(keys).toEqual(["files", "activity-log", "git-manager", "secrets", "pull-requests"]);
|
||||
expect(keys).not.toContain("devserver");
|
||||
expect(keys).not.toContain("todos");
|
||||
// Usage moved back to the top header; it is no longer a right-dock key.
|
||||
expect(keys).not.toContain("usage");
|
||||
});
|
||||
|
||||
it("does not expose left-sidebar content views or removed dock tools in the registry", () => {
|
||||
// github-import and automation were moved off the dock into left-sidebar / main views.
|
||||
const removedKeys = [
|
||||
"documents",
|
||||
"research",
|
||||
"insights",
|
||||
"skills",
|
||||
"memory",
|
||||
"secrets",
|
||||
"stash-recovery",
|
||||
"evals",
|
||||
"goalsView",
|
||||
"todos",
|
||||
"devserver",
|
||||
"github-import",
|
||||
"automation",
|
||||
// Usage moved back to the top header; it is no longer exposed as a dock key.
|
||||
"usage",
|
||||
];
|
||||
const keys = getVisibleOverflowViewEntries({
|
||||
experimentalFeatures: {
|
||||
@@ -57,6 +78,10 @@ describe("overflowViewRegistry", () => {
|
||||
for (const removedKey of removedKeys) {
|
||||
expect(keys).not.toContain(removedKey);
|
||||
}
|
||||
// secrets, todos, pull-requests, devserver are now PRESENT dock tools.
|
||||
for (const presentKey of ["secrets", "todos", "pull-requests", "devserver"]) {
|
||||
expect(keys).toContain(presentKey);
|
||||
}
|
||||
});
|
||||
|
||||
it("adds only non-primary plugin views after static tool entries", () => {
|
||||
@@ -75,17 +100,40 @@ describe("overflowViewRegistry", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const entries = getVisibleOverflowViewEntries({ pluginDashboardViews });
|
||||
const entries = getVisibleOverflowViewEntries({
|
||||
experimentalFeatures: { devServerView: true },
|
||||
todosEnabled: true,
|
||||
pluginDashboardViews,
|
||||
});
|
||||
expect(entries.map((entry) => entry.key)).toEqual([
|
||||
"usage",
|
||||
"activity-log",
|
||||
"github-import",
|
||||
"git-manager",
|
||||
"files",
|
||||
"automation",
|
||||
"activity-log",
|
||||
"git-manager",
|
||||
"devserver",
|
||||
"secrets",
|
||||
"todos",
|
||||
"pull-requests",
|
||||
"plugin:plugin-b:audit",
|
||||
"plugin:plugin-a:tools",
|
||||
]);
|
||||
expect(entries.some((entry) => entry.key === "plugin:plugin-a:primary")).toBe(false);
|
||||
});
|
||||
|
||||
it("excludes the dependency-graph plugin from the right dock", () => {
|
||||
const pluginDashboardViews: PluginDashboardViewEntry[] = [
|
||||
{
|
||||
pluginId: "fusion-plugin-dependency-graph",
|
||||
view: { viewId: "graph", label: "Dependency Graph", placement: "overflow", order: 1 },
|
||||
},
|
||||
{
|
||||
pluginId: "plugin-c",
|
||||
view: { viewId: "report", label: "Report", placement: "overflow", order: 2 },
|
||||
},
|
||||
];
|
||||
|
||||
const keys = getVisibleOverflowViewEntries({ pluginDashboardViews }).map((entry) => entry.key);
|
||||
|
||||
expect(keys).not.toContain("plugin:fusion-plugin-dependency-graph:graph");
|
||||
expect(keys).toContain("plugin:plugin-c:report");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Suspense, lazy, type ComponentType, type ReactNode } from "react";
|
||||
import {
|
||||
Activity,
|
||||
CheckSquare,
|
||||
Folder,
|
||||
GitBranch,
|
||||
@@ -19,7 +18,6 @@ import type { DetailTaskTab, PluginDashboardViewContext } from "../plugins/types
|
||||
import { DockFilesView } from "./DockFilesView";
|
||||
import { PageErrorBoundary } from "./ErrorBoundary";
|
||||
import { getPluginNavIcon } from "./pluginNavIcon";
|
||||
import { UsageIndicator } from "./UsageIndicator";
|
||||
import { ActivityLogModal } from "./ActivityLogModal";
|
||||
import { GitManagerModal } from "./GitManagerModal";
|
||||
|
||||
@@ -124,15 +122,6 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [
|
||||
testId: "right-dock-tab-files",
|
||||
render: (props) => wrapOverflowView(<DockFilesView projectId={props.projectId} openFile={props.openFile} />),
|
||||
},
|
||||
{
|
||||
key: "usage",
|
||||
label: "Activity",
|
||||
icon: Activity,
|
||||
testId: "right-dock-tab-usage",
|
||||
render: (props) => wrapOverflowView(
|
||||
<UsageIndicator isOpen={true} onClose={() => {}} projectId={props.projectId} presentation="embedded" />,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "activity-log",
|
||||
label: "Activity Log",
|
||||
|
||||
Reference in New Issue
Block a user