From 744bb384dcb04b0697cc26574f03e6f6cab24e70 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 01:54:38 -0700 Subject: [PATCH] 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) --- .../__tests__/tablet-header-controls.test.tsx | 204 ++++++++---------- packages/dashboard/app/components/Header.tsx | 16 ++ .../app/components/LeftSidebarNav.tsx | 16 +- .../app/components/__tests__/Header.test.tsx | 29 ++- .../__tests__/LeftSidebarNav.test.tsx | 83 ++++++- .../__tests__/overflowViewRegistry.test.tsx | 100 ++++++--- .../app/components/overflowViewRegistry.tsx | 11 - 7 files changed, 285 insertions(+), 174 deletions(-) diff --git a/packages/dashboard/app/__tests__/tablet-header-controls.test.tsx b/packages/dashboard/app/__tests__/tablet-header-controls.test.tsx index e25c42a16e..6aef4c5e81 100644 --- a/packages/dashboard/app/__tests__/tablet-header-controls.test.tsx +++ b/packages/dashboard/app/__tests__/tablet-header-controls.test.tsx @@ -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("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("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(); }); }); }); diff --git a/packages/dashboard/app/components/Header.tsx b/packages/dashboard/app/components/Header.tsx index 103056365b..608a086f55 100644 --- a/packages/dashboard/app/components/Header.tsx +++ b/packages/dashboard/app/components/Header.tsx @@ -974,6 +974,22 @@ export function Header({ {/* Plugin UI slot for header actions */} + {/* + 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 && ( + + )} + {/* 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. diff --git a/packages/dashboard/app/components/LeftSidebarNav.tsx b/packages/dashboard/app/components/LeftSidebarNav.tsx index 9aec1a5800..26367121aa 100644 --- a/packages/dashboard/app/components/LeftSidebarNav.tsx +++ b/packages/dashboard/app/components/LeftSidebarNav.tsx @@ -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") }] : []), diff --git a/packages/dashboard/app/components/__tests__/Header.test.tsx b/packages/dashboard/app/components/__tests__/Header.test.tsx index 0a60887446..9769e95675 100644 --- a/packages/dashboard/app/components/__tests__/Header.test.tsx +++ b/packages/dashboard/app/components/__tests__/Header.test.tsx @@ -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", () => { diff --git a/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx b/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx index 157059312d..004d2865a6 100644 --- a/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx +++ b/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx @@ -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")); diff --git a/packages/dashboard/app/components/__tests__/overflowViewRegistry.test.tsx b/packages/dashboard/app/components/__tests__/overflowViewRegistry.test.tsx index f72b834948..ae074bfe1d 100644 --- a/packages/dashboard/app/components/__tests__/overflowViewRegistry.test.tsx +++ b/packages/dashboard/app/components/__tests__/overflowViewRegistry.test.tsx @@ -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"); + }); }); diff --git a/packages/dashboard/app/components/overflowViewRegistry.tsx b/packages/dashboard/app/components/overflowViewRegistry.tsx index ae60aad315..29f049b516 100644 --- a/packages/dashboard/app/components/overflowViewRegistry.tsx +++ b/packages/dashboard/app/components/overflowViewRegistry.tsx @@ -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(), }, - { - key: "usage", - label: "Activity", - icon: Activity, - testId: "right-dock-tab-usage", - render: (props) => wrapOverflowView( - {}} projectId={props.projectId} presentation="embedded" />, - ), - }, { key: "activity-log", label: "Activity Log",