FN-6696: promote Command Center in responsive navigation
Promote Command Center into fixed responsive nav positions while preserving overflow access for displaced views. - Add tablet Header behavior that shows Command Center inline after Agents and moves Documents to the overflow menu. - Add mobile top-level Command Center tab immediately after Mailbox and demote primary plugin tabs to More. - Update navigation tests for tablet, desktop, mobile, and plugin overflow ordering. - Add an xs font-size token for mailbox tab reuse. Files changed: .../app/__tests__/tablet-header-controls.test.tsx | 24 +++++++ packages/dashboard/app/components/Header.tsx | 82 ++++++++++++++++------ packages/dashboard/app/components/MailboxModal.css | 4 +- packages/dashboard/app/components/MobileNavBar.tsx | 31 ++++---- .../app/components/__tests__/Header.test.tsx | 24 +++++++ .../app/components/__tests__/MobileNavBar.test.tsx | 47 ++++++++++++- packages/dashboard/app/styles.css | 3 +- 7 files changed, 174 insertions(+), 41 deletions(-) Fusion-Task-Id: FN-6696 Fusion-Task-Lineage: e24bb740-7429-40f1-8612-d9af03486d26
This commit is contained in:
@@ -94,12 +94,36 @@ describe("tablet header controls", () => {
|
||||
expect(screen.getByTitle("Board view")).toBeDefined();
|
||||
expect(screen.getByTitle("List view")).toBeDefined();
|
||||
expect(screen.getByTitle("Agents view")).toBeDefined();
|
||||
expect(screen.getByTestId("view-toggle-command-center")).toBeDefined();
|
||||
expect(screen.queryByTitle("Documents view")).toBeNull();
|
||||
// Skills and Insights are NOT inline (they're in overflow)
|
||||
expect(screen.queryByTitle("Skills view")).toBeNull();
|
||||
expect(screen.queryByTitle("Roadmaps view")).toBeNull();
|
||||
expect(screen.queryByTitle("Insights view")).toBeNull();
|
||||
});
|
||||
|
||||
it("places tablet Command Center inline immediately after Agents and Documents only in overflow", () => {
|
||||
renderTabletHeader({ onChangeView: noop, showAgentsTab: true });
|
||||
|
||||
expect(screen.getByTestId("view-toggle-command-center").previousElementSibling).toBe(screen.getByTitle("Agents view"));
|
||||
expect(screen.queryByTitle("Documents view")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||
expect(screen.getByTestId("view-overflow-documents")).toBeDefined();
|
||||
expect(screen.queryByTestId("view-overflow-command-center")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps desktop Documents inline and Command Center in overflow", () => {
|
||||
renderDesktopHeader({ onChangeView: noop, showAgentsTab: true });
|
||||
|
||||
expect(screen.getByTitle("Documents view")).toBeDefined();
|
||||
expect(screen.queryByTestId("view-toggle-command-center")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||
expect(screen.getByTestId("view-overflow-command-center")).toBeDefined();
|
||||
expect(screen.queryByTestId("view-overflow-documents")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders view toggle overflow trigger on tablet when overflow items are available", () => {
|
||||
renderTabletHeader({ onChangeView: noop, experimentalFeatures: { insights: true } });
|
||||
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
|
||||
|
||||
@@ -260,9 +260,10 @@ export function Header({
|
||||
experimentalFeatures?.memoryView ||
|
||||
experimentalFeatures?.devServerView ||
|
||||
!hideFullNav ||
|
||||
isTablet ||
|
||||
pluginDashboardViews.some((entry) => entry.view.placement !== "primary")
|
||||
);
|
||||
}, [onChangeView, experimentalFeatures, todosEnabled, showSkillsTab, hideFullNav, pluginDashboardViews]);
|
||||
}, [onChangeView, experimentalFeatures, todosEnabled, showSkillsTab, hideFullNav, isTablet, pluginDashboardViews]);
|
||||
|
||||
const getEffectiveViewport = useCallback(() => {
|
||||
const vv = window.visualViewport;
|
||||
@@ -1018,6 +1019,23 @@ export function Header({
|
||||
<Bot size={16} />
|
||||
</button>
|
||||
)}
|
||||
{isTablet && (
|
||||
/*
|
||||
FNXC:Navigation 2026-06-19-12:00:
|
||||
Tablet navigation promotes Command Center immediately after Agents while desktop keeps Command Center in the More-views overflow.
|
||||
Documents moves to the tablet More-views overflow below to conserve horizontal space without changing desktop ordering.
|
||||
*/
|
||||
<button
|
||||
className={`view-toggle-btn${view === "command-center" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("command-center")}
|
||||
title={t("header.commandCenterView", "Command Center")}
|
||||
aria-label={t("header.commandCenterView", "Command Center")}
|
||||
aria-pressed={view === "command-center"}
|
||||
data-testid="view-toggle-command-center"
|
||||
>
|
||||
<Gauge size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`view-toggle-btn${view === "missions" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("missions")}
|
||||
@@ -1040,15 +1058,17 @@ export function Header({
|
||||
<span className="status-dot status-dot--pending header-chat-unread-dot" aria-label={t("header.unreadChatResponse", "Unread chat response")} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={`view-toggle-btn${view === "documents" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("documents")}
|
||||
title={t("header.documentsView", "Documents view")}
|
||||
aria-label={t("header.documentsView", "Documents view")}
|
||||
aria-pressed={view === "documents"}
|
||||
>
|
||||
<FileText size={16} />
|
||||
</button>
|
||||
{!isTablet && (
|
||||
<button
|
||||
className={`view-toggle-btn${view === "documents" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("documents")}
|
||||
title={t("header.documentsView", "Documents view")}
|
||||
aria-label={t("header.documentsView", "Documents view")}
|
||||
aria-pressed={view === "documents"}
|
||||
>
|
||||
<FileText size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`view-toggle-btn${view === "mailbox" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("mailbox")}
|
||||
@@ -1090,7 +1110,7 @@ export function Header({
|
||||
<>
|
||||
<button
|
||||
ref={viewOverflowTriggerRef}
|
||||
className={`view-toggle-btn${["research", "skills", "insights", "memory", "secrets", "reliability", "command-center", "dev-server", "devserver", "graph", "stash-recovery"].includes(view) || (experimentalFeatures?.evalsView && view === "evals") || (experimentalFeatures?.goalsView && view === "goalsView") || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
|
||||
className={`view-toggle-btn${["research", "skills", "insights", "memory", "secrets", "reliability", "dev-server", "devserver", "graph", "stash-recovery"].includes(view) || (!isTablet && view === "command-center") || (isTablet && view === "documents") || (experimentalFeatures?.evalsView && view === "evals") || (experimentalFeatures?.goalsView && view === "goalsView") || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
|
||||
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
|
||||
title={t("header.moreViews", "More views")}
|
||||
aria-label={t("header.moreViews", "More views")}
|
||||
@@ -1230,18 +1250,34 @@ export function Header({
|
||||
<Activity size={14} />
|
||||
<span>{t("header.reliabilityView", "Reliability")}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`view-toggle-overflow-item${view === "command-center" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
onChangeView("command-center");
|
||||
setIsViewOverflowOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
data-testid="view-overflow-command-center"
|
||||
>
|
||||
<Gauge size={14} />
|
||||
<span>{t("header.commandCenterView", "Command Center")}</span>
|
||||
</button>
|
||||
{isTablet && (
|
||||
<button
|
||||
className={`view-toggle-overflow-item${view === "documents" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
onChangeView("documents");
|
||||
setIsViewOverflowOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
data-testid="view-overflow-documents"
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span>{t("header.documentsView", "Documents view")}</span>
|
||||
</button>
|
||||
)}
|
||||
{!isTablet && (
|
||||
<button
|
||||
className={`view-toggle-overflow-item${view === "command-center" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
onChangeView("command-center");
|
||||
setIsViewOverflowOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
data-testid="view-overflow-command-center"
|
||||
>
|
||||
<Gauge size={14} />
|
||||
<span>{t("header.commandCenterView", "Command Center")}</span>
|
||||
</button>
|
||||
)}
|
||||
{experimentalFeatures?.devServerView && (
|
||||
<button
|
||||
className={`view-toggle-overflow-item${view === "dev-server" || view === "devserver" ? " active" : ""}`}
|
||||
|
||||
@@ -789,7 +789,7 @@
|
||||
.mailbox-modal .mailbox-tab {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--font-size-xs, 0.8rem);
|
||||
}
|
||||
|
||||
.mailbox-modal .mailbox-content {
|
||||
@@ -881,7 +881,7 @@
|
||||
.mailbox-view .mailbox-tab {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--font-size-xs, 0.8rem);
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-content {
|
||||
|
||||
@@ -270,14 +270,19 @@ export function MobileNavBar({
|
||||
const skillsEnabled = Boolean(showSkillsTab);
|
||||
const todoViewEnabled = Boolean(experimentalFeatures?.todoView);
|
||||
|
||||
// Keep a maximum of one optional primary tab visible at once to preserve touch-target width.
|
||||
// Keep optional primary tabs limited to preserve touch-target width.
|
||||
// Overflowed destinations remain available in the More sheet.
|
||||
const showSkillsTopLevel = skillsEnabled;
|
||||
const showSkillsInMore = skillsEnabled && !showSkillsTopLevel;
|
||||
const sortedPrimaryPluginViews = pluginDashboardViews
|
||||
.filter((entry) => entry.view.placement === "primary")
|
||||
.sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER));
|
||||
const MAX_PRIMARY_PLUGIN_TOP_LEVEL_TABS = 1;
|
||||
/*
|
||||
FNXC:Navigation 2026-06-19-12:05:
|
||||
Mobile navigation adds Command Center as a fixed top-level tab immediately after Mailbox.
|
||||
Primary plugin tabs, including Compound Engineering, are demoted to the More sheet so touch targets stay wide and Command Center is not duplicated.
|
||||
*/
|
||||
const MAX_PRIMARY_PLUGIN_TOP_LEVEL_TABS = 0;
|
||||
const topLevelPrimaryPluginViews = sortedPrimaryPluginViews.slice(0, MAX_PRIMARY_PLUGIN_TOP_LEVEL_TABS);
|
||||
const topLevelPluginViewKeys = new Set(
|
||||
topLevelPrimaryPluginViews.map((entry) => `${entry.pluginId}:${entry.view.viewId}`),
|
||||
@@ -289,7 +294,6 @@ export function MobileNavBar({
|
||||
const isMoreActive =
|
||||
view === "documents"
|
||||
|| view === "reliability"
|
||||
|| view === "command-center"
|
||||
|| (Boolean(experimentalFeatures?.evalsView) && view === "evals")
|
||||
|| (Boolean(experimentalFeatures?.goalsView) && view === "goalsView")
|
||||
|| view === "research"
|
||||
@@ -393,6 +397,18 @@ export function MobileNavBar({
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`mobile-nav-tab${view === "command-center" ? " mobile-nav-tab--active" : ""}`}
|
||||
data-testid="mobile-nav-tab-command-center"
|
||||
role="tab"
|
||||
aria-selected={view === "command-center"}
|
||||
onClick={() => onChangeView("command-center")}
|
||||
>
|
||||
<Gauge />
|
||||
<span className="mobile-nav-tab-label">{t("nav.commandCenter", "Command Center")}</span>
|
||||
</button>
|
||||
|
||||
{showSkillsTopLevel && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -671,15 +687,6 @@ export function MobileNavBar({
|
||||
<span>{t("nav.reliability", "Reliability")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-more-item"
|
||||
data-testid="mobile-more-item-command-center"
|
||||
onClick={() => handleMoreAction(() => onChangeView("command-center"))}
|
||||
>
|
||||
<Gauge />
|
||||
<span>{t("nav.commandCenter", "Command Center")}</span>
|
||||
</button>
|
||||
{experimentalFeatures?.evalsView && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -299,6 +299,30 @@ describe("Header", () => {
|
||||
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps desktop Documents inline and Command Center only in overflow", () => {
|
||||
renderHeader({ onChangeView: noop, showAgentsTab: true }, "desktop");
|
||||
|
||||
expect(screen.getByTitle("Documents view")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("view-toggle-command-center")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||
expect(screen.getByTestId("view-overflow-command-center")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("view-overflow-documents")).toBeNull();
|
||||
});
|
||||
|
||||
it("promotes Command Center after Agents and moves Documents to overflow on tablet", () => {
|
||||
renderHeader({ onChangeView: noop, showAgentsTab: true }, "tablet");
|
||||
|
||||
const agentsButton = screen.getByTitle("Agents view");
|
||||
const commandCenterButton = screen.getByTestId("view-toggle-command-center");
|
||||
expect(commandCenterButton.previousElementSibling).toBe(agentsButton);
|
||||
expect(screen.queryByTitle("Documents view")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||
expect(screen.getByTestId("view-overflow-documents")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("view-overflow-command-center")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders view overflow trigger when skills tab is enabled", () => {
|
||||
renderHeader({ onChangeView: noop, showSkillsTab: true });
|
||||
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("MobileNavBar", () => {
|
||||
mockViewport("mobile");
|
||||
});
|
||||
|
||||
it("renders seven tab buttons (tasks + agents + missions + chat + mailbox + skills + more) when showSkillsTab is true", () => {
|
||||
it("renders eight tab buttons (tasks + agents + missions + chat + mailbox + command center + skills + more) when showSkillsTab is true", () => {
|
||||
render(<MobileNavBar {...createDefaultProps()} showSkillsTab={true} />);
|
||||
|
||||
expect(screen.getByTestId("mobile-nav-tab-tasks")).toBeDefined();
|
||||
@@ -66,6 +66,7 @@ describe("MobileNavBar", () => {
|
||||
expect(screen.getByTestId("mobile-nav-tab-missions")).toBeDefined();
|
||||
expect(screen.getByTestId("mobile-nav-tab-chat")).toBeDefined();
|
||||
expect(screen.getByTestId("mobile-nav-tab-mailbox")).toBeDefined();
|
||||
expect(screen.getByTestId("mobile-nav-tab-command-center")).toBeDefined();
|
||||
expect(screen.getByTestId("mobile-nav-tab-skills")).toBeDefined();
|
||||
expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull();
|
||||
expect(screen.getByTestId("mobile-nav-tab-more")).toBeDefined();
|
||||
@@ -173,7 +174,7 @@ describe("MobileNavBar", () => {
|
||||
expect(props.onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:queue");
|
||||
});
|
||||
|
||||
it("limits primary plugin tabs on mobile and overflows extra primary views into More", () => {
|
||||
it("demotes primary plugin tabs on mobile and renders them in More", () => {
|
||||
render(
|
||||
<MobileNavBar
|
||||
{...createDefaultProps()}
|
||||
@@ -190,10 +191,11 @@ describe("MobileNavBar", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("mobile-nav-tab-plugin-fusion-plugin-dependency-graph-graph")).toBeDefined();
|
||||
expect(screen.queryByTestId("mobile-nav-tab-plugin-fusion-plugin-dependency-graph-graph")).toBeNull();
|
||||
expect(screen.queryByTestId("mobile-nav-tab-plugin-fusion-plugin-dependency-graph-queue")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||
expect(screen.getByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-graph")).toBeDefined();
|
||||
expect(screen.getByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-queue")).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -252,6 +254,22 @@ describe("MobileNavBar", () => {
|
||||
expect(props.onChangeView).toHaveBeenCalledWith("mailbox");
|
||||
});
|
||||
|
||||
it("places Command Center immediately after Mailbox and routes from the top-level tab", () => {
|
||||
const props = createDefaultProps();
|
||||
render(<MobileNavBar {...props} view="board" mailboxUnreadCount={3} mailboxPendingApprovalCount={1} />);
|
||||
|
||||
const mailboxTab = screen.getByTestId("mobile-nav-tab-mailbox");
|
||||
const commandCenterTab = screen.getByTestId("mobile-nav-tab-command-center");
|
||||
expect(mailboxTab.compareDocumentPosition(commandCenterTab) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(commandCenterTab.previousElementSibling).toBe(mailboxTab);
|
||||
|
||||
fireEvent.click(commandCenterTab);
|
||||
expect(props.onChangeView).toHaveBeenCalledWith("command-center");
|
||||
|
||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||
expect(screen.queryByTestId("mobile-more-item-command-center")).toBeNull();
|
||||
});
|
||||
|
||||
it("agents tab calls onChangeView with 'agents'", () => {
|
||||
const props = createDefaultProps();
|
||||
render(<MobileNavBar {...props} view="board" />);
|
||||
@@ -401,6 +419,7 @@ describe("MobileNavBar", () => {
|
||||
expect(screen.getByTestId("mobile-more-item-github")).toBeDefined();
|
||||
expect(screen.getByTestId("mobile-more-item-usage")).toBeDefined();
|
||||
expect(screen.getByTestId("mobile-more-item-projects")).toBeDefined();
|
||||
expect(screen.queryByTestId("mobile-more-item-command-center")).toBeNull();
|
||||
expect(screen.queryByTestId("mobile-more-item-chat")).toBeNull();
|
||||
expect(screen.queryByTestId("mobile-more-item-roadmaps")).toBeNull();
|
||||
expect(screen.queryByTestId("mobile-more-item-insights")).toBeNull();
|
||||
@@ -413,6 +432,28 @@ describe("MobileNavBar", () => {
|
||||
expect(screen.queryByTestId("mobile-more-item-roadmaps")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders Compound Engineering primary plugin only in the More sheet while Command Center follows Mailbox", () => {
|
||||
render(
|
||||
<MobileNavBar
|
||||
{...createDefaultProps()}
|
||||
pluginDashboardViews={[
|
||||
{
|
||||
pluginId: "fusion-plugin-compound-engineering",
|
||||
view: { viewId: "compound-engineering", label: "Compound Engineering", componentPath: "./CompoundEngineeringView", icon: "Sparkles", placement: "primary", order: 36 },
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("mobile-nav-tab-command-center").previousElementSibling).toBe(screen.getByTestId("mobile-nav-tab-mailbox"));
|
||||
expect(screen.queryByTestId("mobile-nav-tab-plugin-fusion-plugin-compound-engineering-compound-engineering")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||
expect(screen.getByTestId("mobile-more-item-plugin-fusion-plugin-compound-engineering-compound-engineering")).toBeDefined();
|
||||
expect(screen.queryAllByTestId("mobile-more-item-plugin-fusion-plugin-compound-engineering-compound-engineering")).toHaveLength(1);
|
||||
expect(screen.queryByTestId("mobile-more-item-command-center")).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses legacy roadmaps entries when roadmap plugin view is registered", () => {
|
||||
render(
|
||||
<MobileNavBar
|
||||
|
||||
@@ -120,6 +120,7 @@ html {
|
||||
/* Typography */
|
||||
--font-primary: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
--font-mono: "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--font-size-xs: 0.8rem;
|
||||
|
||||
/* Spacing Scale */
|
||||
--space-xs: 4px;
|
||||
@@ -667,7 +668,7 @@ html .column.drag-over * {
|
||||
* Runtime provider settings card — unified layout used by Hermes / OpenClaw /
|
||||
* Paperclip cards. Header (large logo + name + status), description, form,
|
||||
* footer action row.
|
||||
* font-size values intentionally raw — no --font-size-* tokens defined yet
|
||||
* FNXC:DashboardCssTokens 2026-06-19-05:07: Most font-size values remain intentionally raw; --font-size-xs exists for mobile tab-size reuse where token validity tests require a defined custom property.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
.runtime-card {
|
||||
|
||||
Reference in New Issue
Block a user