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:
gsxdsm
2026-06-22 01:54:38 -07:00
parent 0b410d54e1
commit 744bb384dc
7 changed files with 285 additions and 174 deletions

View File

@@ -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();
});
});
});

View File

@@ -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.

View File

@@ -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") }]
: []),

View File

@@ -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", () => {

View File

@@ -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"));

View File

@@ -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");
});
});

View File

@@ -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",