FN-6717: move Nodes into Command Center

Move node management into the Command Center experience while removing the separate Nodes overlay entry points.

- Add a gated Nodes tab to Command Center and pass toast wiring through from App.
- Remove top-level header and mobile-nav Nodes overlay controls and overlay-only styling.
- Let NodesView render without a close button when embedded as a tab.
- Update navigation, lazy-load docs, mobile access, and Command Center tests for the new surface.

Files changed:
 AGENTS.md                                          |  3 +-
 packages/dashboard/app/App.tsx                     | 57 ++-------------
 .../app/__tests__/lazy-loaded-views-docs.test.ts   | 11 +--
 .../mobile-feature-access-regression.test.tsx      | 12 ++--
 packages/dashboard/app/components/Header.tsx       | 33 +--------
 packages/dashboard/app/components/MobileNavBar.tsx | 16 -----
 packages/dashboard/app/components/NodesView.css    | 37 ----------
 packages/dashboard/app/components/NodesView.tsx    | 22 +++---
 .../app/components/__tests__/Header.test.tsx       | 22 ++----
 .../app/components/__tests__/MobileNavBar.test.tsx | 22 +-----
 .../app/components/__tests__/NodesView.test.tsx    | 81 +++-------------------
 ...-merge-toggle-blank.mobile-integration.test.tsx |  1 -
 .../components/command-center/CommandCenter.tsx    | 17 ++++-
 .../__tests__/CommandCenter.mobile-scroll.test.tsx |  2 +-
 .../__tests__/CommandCenter.tablet-layout.test.tsx |  2 +-
 .../__tests__/CommandCenter.test.tsx               | 40 ++++++++++-
 packages/dashboard/app/styles.css                  |  2 +-
 packages/i18n/locales/en/app.json                  |  1 +
 18 files changed, 106 insertions(+), 275 deletions(-)

Fusion-Task-Id: FN-6717

Fusion-Task-Lineage: 89306335-c247-4c66-8937-adfdf8c3cbb8
This commit is contained in:
gsxdsm
2026-06-19 23:16:19 -07:00
parent f3343ca79f
commit a326e8e684
18 changed files with 106 additions and 275 deletions

View File

@@ -218,11 +218,10 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
### Lazy-Loaded Heavy Views
These 22 views are lazy-loaded via `React.lazy()` with `<Suspense fallback={null}>`.
These 21 views are lazy-loaded via `React.lazy()` with `<Suspense fallback={null}>`.
Keep this AGENTS inventory in sync with App lazy imports, AppModals lazy modal imports (`SettingsModal`, `WorkflowNodeEditor`, `SetupWizardModal`), and `packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts`.
- `AgentsView`
- `NodesView`
- `ChatView`
- `MemoryView`
- `DevServerView`

View File

@@ -109,7 +109,6 @@ const DocumentsView = lazy(() => import("./components/DocumentsView").then((m) =
const InsightsView = lazy(() => import("./components/InsightsView").then((m) => ({ default: m.InsightsView })));
const ResearchView = lazy(() => import("./components/ResearchView").then((m) => ({ default: m.ResearchView })));
const EvalsView = lazy(() => import("./components/EvalsView").then((m) => ({ default: m.EvalsView })));
const NodesView = lazy(() => import("./components/NodesView").then((m) => ({ default: m.NodesView })));
const ChatView = lazy(() => import("./components/ChatView").then((m) => ({ default: m.ChatView })));
const SkillsView = lazy(() => import("./components/SkillsView").then((m) => ({ default: m.SkillsView })));
@@ -139,7 +138,6 @@ function prefetchLazyViews() {
void import("./components/InsightsView");
void import("./components/ResearchView");
void import("./components/EvalsView");
void import("./components/NodesView");
void import("./components/ChatView");
void import("./components/SkillsView");
@@ -848,11 +846,6 @@ function AppInner() {
});
}, [boardSourceTasks, branchFilter, baseBranchFilter]);
// Nodes management is an overlay view (not a modal), so it stays local to App.
const [nodesOpen, setNodesOpen] = useState(false);
const closeNodes = useCallback(() => {
setNodesOpen(false);
}, []);
const [retryingProjects, setRetryingProjects] = useState(false);
const [missionResumeSessionId, setMissionResumeSessionId] = useState<string | undefined>(undefined);
const [missionTargetId, setMissionTargetId] = useState<string | undefined>(undefined);
@@ -1089,13 +1082,6 @@ function AppInner() {
}
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled, evalsEnabled, goalsEnabled, graphPluginTaskView]);
// Auto-close nodes overlay if feature flag is toggled off while overlay is open
useEffect(() => {
if (nodesOpen && !nodesEnabled) {
removeNav(closeNodes);
closeNodes();
}
}, [closeNodes, nodesOpen, nodesEnabled, removeNav]);
const {
availableModels,
favoriteProviders,
@@ -1231,27 +1217,6 @@ function AppInner() {
[workflowSteps],
);
const _handleOpenNodes = useCallback(() => {
if (!nodesEnabled) return;
setNodesOpen((prev) => !prev);
}, [nodesEnabled]);
const closeNodesWithNav = useCallback(() => {
removeNav(closeNodes);
closeNodes();
}, [closeNodes, removeNav]);
// History-aware nodes toggle — pushes nav entry only when opening
const handleOpenNodesWithNav = useCallback(() => {
if (!nodesEnabled) return;
if (!nodesOpen) {
setNodesOpen(true);
pushNav({ type: "view", revert: closeNodes });
} else {
closeNodesWithNav();
}
}, [closeNodes, closeNodesWithNav, nodesEnabled, nodesOpen, pushNav]);
// History-aware modal open handlers — push nav entries for back-navigation.
const openDetailTask = useCallback((task: Task | TaskDetail, tab?: Parameters<typeof modalManager.openDetailTask>[1], opts?: { origin?: DetailTaskOrigin }) => {
modalManager.openDetailTask(task, tab, opts);
@@ -1525,18 +1490,6 @@ function AppInner() {
);
}
if (nodesOpen) {
return (
<div className="nodes-management-overlay">
<PageErrorBoundary>
<Suspense fallback={null}>
<NodesView addToast={addToast} onClose={closeNodesWithNav} />
</Suspense>
</PageErrorBoundary>
</div>
);
}
if (viewMode === "overview") {
return (
<PageErrorBoundary>
@@ -1825,6 +1778,8 @@ function AppInner() {
themeMode={themeMode}
onColorThemeChange={setColorTheme}
onThemeModeChange={setThemeMode}
addToast={addToast}
nodesEnabled={nodesEnabled}
/>
</Suspense>
</PageErrorBoundary>
@@ -1969,8 +1924,6 @@ function AppInner() {
stashOrphanCount={stashOrphanCount}
onOpenSchedules={openSchedulesWithNav}
onOpenGitManager={openGitManagerWithNav}
onOpenNodes={handleOpenNodesWithNav}
showNodesButton={nodesEnabled}
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onOpenScripts={openScriptsWithNav}
onRunScript={runScriptWithNav}
@@ -2040,7 +1993,7 @@ function AppInner() {
/>
</>
)}
{viewMode === "project" && currentProject && !nodesOpen && taskView !== "missions" && !modalManager.isPlanningOpen && !sessionBannersHidden && (
{viewMode === "project" && currentProject && taskView !== "missions" && !modalManager.isPlanningOpen && !sessionBannersHidden && (
<SessionNotificationBanner
sessions={sessionsNeedingInput}
onResumeSession={handleOpenBackgroundSession}
@@ -2143,7 +2096,7 @@ function AppInner() {
>
{renderMainContent()}
</div>
{viewMode === "project" && currentProject && !nodesOpen && (
{viewMode === "project" && currentProject && (
<ExecutorStatusBar
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
projectId={currentProject.id}
@@ -2170,7 +2123,6 @@ function AppInner() {
onOpenSettings={openSettingsWithNav}
onOpenActivityLog={openActivityLogWithNav}
onOpenMailbox={() => handleTaskViewChange("mailbox")}
onOpenNodes={handleOpenNodesWithNav}
mailboxUnreadCount={mailboxUnreadCount}
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
chatHasUnreadResponse={chatHasUnreadResponse}
@@ -2201,7 +2153,6 @@ function AppInner() {
researchView: researchEnabled,
evalsView: evalsEnabled,
goalsView: goalsEnabled,
nodesView: nodesEnabled,
}}
pluginDashboardViews={pluginDashboardViews}
shellConnectionControl={

View File

@@ -8,6 +8,9 @@ Merging main reconciled the curated count to 23 (main's 22 lazy views/modals + C
FNXC:CommandCenter 2026-06-19-00:00:
FN-6702 removes ReliabilityView from the App-level lazy inventory because Reliability now mounts inside the lazy CommandCenter chunk.
FNXC:CommandCenter 2026-06-19-00:00:
FN-6717 removes NodesView from the App-level lazy inventory because Nodes now mounts inside the lazy CommandCenter chunk.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
@@ -15,7 +18,6 @@ import { resolve } from "node:path";
const EXPECTED_DOCUMENTED_VIEWS = new Set([
"AgentsView",
"NodesView",
"ChatView",
"MemoryView",
"DevServerView",
@@ -44,7 +46,6 @@ const EXPECTED_APP_LEVEL_VIEWS = new Set([
"InsightsView",
"ResearchView",
"EvalsView",
"NodesView",
"ChatView",
"SkillsView",
"MemoryView",
@@ -106,7 +107,7 @@ function extractAppModalsLazyViews(appModalsSource: string): Set<string> {
}
describe("AGENTS lazy-loaded views inventory", () => {
it("documents the App-level and AppModals lazy views accurately and keeps the curated 22-view list in sync", () => {
it("documents the App-level and AppModals lazy views accurately and keeps the curated 21-view list in sync", () => {
const agentsDoc = readFileSync(resolve(__dirname, "../../../../AGENTS.md"), "utf-8");
const appSource = readFileSync(resolve(__dirname, "../App.tsx"), "utf-8");
const appModalsSource = readFileSync(resolve(__dirname, "../components/AppModals.tsx"), "utf-8");
@@ -114,11 +115,11 @@ describe("AGENTS lazy-loaded views inventory", () => {
const section = extractLazyLoadedSection(agentsDoc);
const countMatch = section.match(/These\s+(\d+)\s+views\s+are lazy-loaded/);
expect(countMatch).toBeTruthy();
expect(Number(countMatch?.[1])).toBe(22);
expect(Number(countMatch?.[1])).toBe(21);
const documentedViews = extractBacktickedNamesFromBullets(section);
expect(new Set(documentedViews)).toEqual(EXPECTED_DOCUMENTED_VIEWS);
expect(documentedViews).toHaveLength(22);
expect(documentedViews).toHaveLength(21);
expect(section).toContain("`ResearchView`");
expect(section).toContain("`TodoView`");

View File

@@ -46,7 +46,6 @@ const createDefaultMobileNavProps = () => ({
onOpenSettings: vi.fn(),
onOpenActivityLog: vi.fn(),
onOpenMailbox: vi.fn(),
onOpenNodes: vi.fn(),
mailboxUnreadCount: 0,
onOpenGitManager: vi.fn(),
onOpenWorkflowEditor: vi.fn(),
@@ -176,16 +175,15 @@ describe("Mobile Feature Access Regression Guard", () => {
expect(props.onChangeView).toHaveBeenCalledWith("command-center");
});
it("nodes view is reachable from mobile More sheet when enabled", () => {
it("nodes is no longer a mobile More item and is reached via Command Center", () => {
const props = createDefaultMobileNavProps();
render(<MobileNavBar {...props} experimentalFeatures={{ nodesView: true }} />);
render(<MobileNavBar {...props} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.queryByTestId("mobile-more-item-nodes")).toBeNull();
const nodesItem = screen.getByTestId("mobile-more-item-nodes");
expect(nodesItem).toBeDefined();
fireEvent.click(nodesItem);
expect(props.onOpenNodes).toHaveBeenCalledOnce();
fireEvent.click(screen.getByTestId("mobile-nav-tab-command-center"));
expect(props.onChangeView).toHaveBeenCalledWith("command-center");
});
it("chat is accessible via the bottom nav while remaining absent from the More sheet", () => {

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Server, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge } from "lucide-react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge } from "lucide-react";
import "./Header.css";
// ProjectSelector styles used by the imported standalone component.
import "./ProjectSelector.css";
@@ -77,9 +77,6 @@ export interface HeaderProps {
stashOrphanCount?: number;
onOpenSchedules?: () => void;
onOpenGitManager?: () => void;
onOpenNodes?: () => void;
/** When false, hides the Nodes management button. Defaults to true for backward compat. */
showNodesButton?: boolean;
onOpenWorkflowEditor?: () => void;
onOpenScripts?: () => void;
onRunScript?: (name: string, command: string) => void;
@@ -146,8 +143,6 @@ export function Header({
stashOrphanCount = 0,
onOpenSchedules,
onOpenGitManager,
onOpenNodes,
showNodesButton,
onOpenWorkflowEditor,
onOpenScripts,
onRunScript,
@@ -1524,20 +1519,6 @@ export function Header({
role="menu"
aria-label={t("header.moreActions", "More actions")}
>
{onOpenNodes && showNodesButton !== false && (
<button
className="view-toggle-overflow-item"
onClick={() => {
onOpenNodes();
setIsDesktopOverflowOpen(false);
}}
role="menuitem"
data-testid="desktop-overflow-nodes-btn"
>
<Server size={14} />
<span>{t("header.nodes", "Nodes")}</span>
</button>
)}
<button
className="view-toggle-overflow-item"
onClick={() => {
@@ -1681,18 +1662,6 @@ export function Header({
<span>{t("header.gitManager", "Git Manager")}</span>
</button>
)}
{/* Nodes - in overflow on mobile */}
{onOpenNodes && showNodesButton !== false && (
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenNodes)}
role="menuitem"
data-testid="overflow-nodes-btn"
>
<Server size={16} />
<span>{t("header.nodes", "Nodes")}</span>
</button>
)}
{!isDesktopShell && (
<button
className="mobile-overflow-item"

View File

@@ -24,7 +24,6 @@ import {
Play,
Settings,
Monitor,
Network,
Search,
Sparkles,
Target,
@@ -108,9 +107,7 @@ export interface MobileNavBarProps {
researchView?: boolean;
evalsView?: boolean;
goalsView?: boolean;
nodesView?: boolean;
};
onOpenNodes?: () => void;
pluginDashboardViews?: PluginDashboardViewEntry[];
shellConnectionControl?: ReactNode;
}
@@ -164,7 +161,6 @@ export function MobileNavBar({
onViewAllProjects,
showSkillsTab,
experimentalFeatures,
onOpenNodes,
pluginDashboardViews = [],
shellConnectionControl,
}: MobileNavBarProps) {
@@ -787,18 +783,6 @@ export function MobileNavBar({
</button>
)}
{experimentalFeatures?.nodesView && onOpenNodes && (
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-nodes"
onClick={() => handleMoreAction(onOpenNodes)}
>
<Network />
<span>{t("nav.nodes", "Nodes")}</span>
</button>
)}
{todoViewEnabled && (
<button
type="button"

View File

@@ -1,18 +1,4 @@
/* === Node Management === */
.nodes-management-overlay {
display: flex;
flex-direction: column;
gap: var(--space-md);
height: 100%;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.nodes-management-overlay__header {
display: flex;
justify-content: flex-end;
}
.nodes-view {
--nodes-view-spin-duration: calc(var(--duration-slow) * 4);
--nodes-view-pulse-duration: calc(var(--duration-slow) * 5);
@@ -605,17 +591,6 @@
/* ── Nodes View Mobile Responsive ──────────────────────────────────────── */
@media (max-width: 768px) {
.nodes-management-overlay {
position: fixed;
inset: 0;
z-index: 50;
background: var(--bg);
padding-top: max(var(--space-sm), env(safe-area-inset-top, 0px));
padding-bottom: calc(var(--mobile-nav-height, 0px) + max(env(safe-area-inset-bottom, 0px), 12px));
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.nodes-view {
padding-inline: var(--space-sm);
}
@@ -718,15 +693,3 @@
font-size: calc(var(--space-md) + var(--space-xs) / 4);
}
}
/* ── Nodes View Tablet Overlay ───────────────────────────────────────── */
@media (min-width: 769px) and (max-width: 1024px) {
.nodes-management-overlay {
position: fixed;
inset: 0;
z-index: 50;
background: var(--bg);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
}

View File

@@ -18,9 +18,13 @@ import type { ToastType } from "../hooks/useToast";
interface NodesViewProps {
addToast: (message: string, type?: ToastType) => void;
onClose: () => void;
onClose?: () => void;
}
/*
FNXC:Nodes 2026-06-19-00:00:
FN-6717 mounts Nodes inside Command Center while preserving the legacy overlay caller during migration/testing. The close affordance is overlay-only, so tab mode omits it when no onClose handler is provided.
*/
export function NodesView({ addToast, onClose }: NodesViewProps) {
const { t } = useTranslation("app");
const {
@@ -151,13 +155,15 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
</div>
<div className="nodes-view-actions">
<button
className="btn-icon nodes-view-close"
onClick={onClose}
aria-label={t("nodes.closeAriaLabel", "Close nodes view")}
>
<X size={16} />
</button>
{onClose ? (
<button
className="btn-icon nodes-view-close"
onClick={onClose}
aria-label={t("nodes.closeAriaLabel", "Close nodes view")}
>
<X size={16} />
</button>
) : null}
<button className="btn btn-sm" onClick={() => void handleRefresh()} disabled={loading || dockerLoading}>
<RefreshCw size={14} className={loading ? "spin" : ""} />
{t("nodes.refresh", "Refresh")}

View File

@@ -1047,27 +1047,17 @@ describe("Header", () => {
});
describe("nodes button", () => {
it("renders Nodes button in desktop overflow when handler is provided", () => {
renderHeader({ onOpenNodes: vi.fn() }, "desktop");
it("omits Nodes button from desktop overflow because Nodes lives in Command Center", () => {
renderHeader({}, "desktop");
expect(screen.getByTestId("desktop-overflow-trigger")).toBeDefined();
fireEvent.click(screen.getByTestId("desktop-overflow-trigger"));
expect(screen.getByTestId("desktop-overflow-nodes-btn")).toBeDefined();
expect(screen.queryByTestId("desktop-overflow-nodes-btn")).toBeNull();
});
it("calls onOpenNodes when Nodes button is clicked from desktop overflow", () => {
const onOpenNodes = vi.fn();
renderHeader({ onOpenNodes }, "desktop");
fireEvent.click(screen.getByTestId("desktop-overflow-trigger"));
fireEvent.click(screen.getByTestId("desktop-overflow-nodes-btn"));
expect(onOpenNodes).toHaveBeenCalled();
});
it("shows Nodes action in mobile overflow menu", () => {
const onOpenNodes = vi.fn();
renderHeader({ onOpenNodes }, "mobile");
it("omits Nodes action from mobile overflow menu because Nodes lives in Command Center", () => {
renderHeader({}, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-nodes-btn"));
expect(onOpenNodes).toHaveBeenCalled();
expect(screen.queryByTestId("overflow-nodes-btn")).toBeNull();
});
});

View File

@@ -34,7 +34,6 @@ const createDefaultProps = () => ({
onOpenSettings: vi.fn(),
onOpenActivityLog: vi.fn(),
onOpenMailbox: vi.fn(),
onOpenNodes: vi.fn(),
mailboxUnreadCount: 0,
mailboxPendingApprovalCount: 0,
onOpenGitManager: vi.fn(),
@@ -507,27 +506,10 @@ describe("MobileNavBar", () => {
expect(screen.queryByTestId("mobile-more-item-research")).toBeNull();
});
it("shows nodes in more sheet only when nodesView is enabled", () => {
const disabledProps = createDefaultProps();
const { unmount } = render(<MobileNavBar {...disabledProps} experimentalFeatures={{}} />);
it("does not show nodes in more sheet because Nodes lives in Command Center", () => {
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{}} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.queryByTestId("mobile-more-item-nodes")).toBeNull();
unmount();
const enabledProps = createDefaultProps();
render(<MobileNavBar {...enabledProps} experimentalFeatures={{ nodesView: true }} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.getByTestId("mobile-more-item-nodes")).toBeDefined();
});
it("invokes onOpenNodes when nodes item is tapped", () => {
const props = createDefaultProps();
render(<MobileNavBar {...props} experimentalFeatures={{ nodesView: true }} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
fireEvent.click(screen.getByTestId("mobile-more-item-nodes"));
expect(props.onOpenNodes).toHaveBeenCalledOnce();
});
it("does not show memory in more sheet when memoryView is not enabled", () => {

View File

@@ -8,7 +8,6 @@ import { useNodeSettingsSync } from "../../hooks/useNodeSettingsSync";
import { useManagedDockerNodes } from "../../hooks/useManagedDockerNodes";
import { useMeshState } from "../../hooks/useMeshState";
import type { NodeSettingsSyncStatus } from "../../api-node";
import { loadAllAppCss } from "../../test/cssFixture";
vi.mock("../../hooks/useNodes", () => ({
useNodes: vi.fn(),
@@ -106,43 +105,6 @@ function makeUseNodesResult(overrides: Partial<ReturnType<typeof useNodes>> = {}
};
}
function extractMediaBlocks(css: string, regex: RegExp): string {
const blocks: string[] = [];
let match: RegExpExecArray | null;
while ((match = regex.exec(css)) !== null) {
const startIdx = match.index + match[0].length;
let braceCount = 1;
let endIdx = startIdx;
while (braceCount > 0 && endIdx < css.length) {
if (css[endIdx] === "{") braceCount += 1;
if (css[endIdx] === "}") braceCount -= 1;
endIdx += 1;
}
if (braceCount === 0) {
blocks.push(css.slice(startIdx, endIdx - 1));
}
}
return blocks.join("\n");
}
function extractMobileMediaBlocks(css: string): string {
return extractMediaBlocks(css, /@media[^{]*\(max-width: 768px\)[^{]*\{/g);
}
function extractTabletMediaBlocks(css: string): string {
return extractMediaBlocks(css, /@media[^{]*\(min-width: 769px\)[^{]*\(max-width: 1024px\)[^{]*\{/g);
}
function extractRuleBlock(css: string, selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const matches = [...css.matchAll(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`, "g"))];
return matches.at(-1)?.[1] ?? "";
}
beforeEach(() => {
mockUseProjects.mockReturnValue({
projects: [],
@@ -188,39 +150,6 @@ beforeEach(() => {
});
describe("NodesView", () => {
it("defines nodes overlay as a fixed fullscreen mobile panel", () => {
const mobileCss = extractMobileMediaBlocks(loadAllAppCss());
const overlayRule = extractRuleBlock(mobileCss, ".nodes-management-overlay");
expect(overlayRule).toContain("position: fixed;");
expect(overlayRule).toContain("inset: 0;");
expect(overlayRule).toContain("z-index: 50;");
expect(overlayRule).toContain("background: var(--bg);");
expect(overlayRule).toContain("padding-top: max(var(--space-sm), env(safe-area-inset-top, 0px));");
expect(overlayRule).toContain("padding-bottom: calc(var(--mobile-nav-height, 0px) + max(env(safe-area-inset-bottom, 0px), 12px));");
expect(overlayRule).toContain("overflow-y: auto;");
expect(overlayRule).toContain("-webkit-overflow-scrolling: touch;");
mockUseNodes.mockReturnValue(makeUseNodesResult({ nodes: [] }));
render(<div className="nodes-management-overlay"><NodesView addToast={vi.fn()} onClose={vi.fn()} /></div>);
const overlay = document.querySelector(".nodes-management-overlay");
expect(overlay).toContainElement(screen.getByTestId("nodes-view"));
expect(screen.getByRole("button", { name: "Close nodes view" })).toBeInTheDocument();
});
it("defines nodes overlay as a fixed fullscreen tablet panel", () => {
const tabletCss = extractTabletMediaBlocks(loadAllAppCss());
const overlayRule = extractRuleBlock(tabletCss, ".nodes-management-overlay");
expect(overlayRule).toContain("position: fixed;");
expect(overlayRule).toContain("inset: 0;");
expect(overlayRule).toContain("z-index: 50;");
expect(overlayRule).toContain("background: var(--bg);");
expect(overlayRule).toContain("overflow-y: auto;");
expect(overlayRule).toContain("-webkit-overflow-scrolling: touch;");
});
it("renders docker stat and passes docker data to matching node card", () => {
mockUseNodes.mockReturnValue(makeUseNodesResult({
nodes: [makeNode({ id: "node-1", name: "Alpha", type: "remote", url: "https://alpha.node" })],
@@ -401,6 +330,16 @@ describe("NodesView", () => {
expect(screen.getByText("Projects (1)")).toBeDefined();
});
it("omits close button when rendered without an overlay close handler", () => {
mockUseNodes.mockReturnValue(makeUseNodesResult({ nodes: [] }));
render(<NodesView addToast={vi.fn()} />);
expect(screen.getByTestId("nodes-view")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Close nodes view" })).toBeNull();
expect(document.querySelector(".nodes-view-close")).toBeNull();
});
it("renders close button and calls onClose when clicked", () => {
mockUseNodes.mockReturnValue(makeUseNodesResult({ nodes: [] }));

View File

@@ -332,7 +332,6 @@ function AppShellMobileHarness({ tasks }: { tasks: Task[] }) {
onOpenUsage={vi.fn()}
onRunScript={vi.fn()}
onViewAllProjects={vi.fn()}
onOpenNodes={vi.fn()}
projectId="proj_123"
activePlanningSessionCount={0}
experimentalFeatures={{}}

View File

@@ -16,6 +16,8 @@ import { SystemStatsArea } from "./areas/SystemStatsArea";
import { MissionControlPanel } from "./MissionControlPanel";
import { CommandCenterControls } from "./CommandCenterControls";
import { ReliabilityView } from "../ReliabilityView";
import { NodesView } from "../NodesView";
import type { ToastType } from "../../hooks/useToast";
import { SdlcFunnel } from "./SdlcFunnel";
import { Bar, type BarDatum } from "./charts/Bar";
import { Sparkline } from "./charts/Sparkline";
@@ -35,6 +37,7 @@ type SubViewId =
| "github"
| "signals"
| "system"
| "nodes"
| "reliability"
| "mission-control";
@@ -49,8 +52,11 @@ Team tab shows each agent's tokens/cost/files-changed/tasks-completed with live
FNXC:CommandCenter 2026-06-19-00:00:
FN-6702 moves Reliability from a top-level dashboard view into a Command Center tab next to System telemetry. Reuse ReliabilityView unchanged so its /api/health/reliability loading, error, insufficient-data, and populated states keep the same data flow.
FNXC:CommandCenter 2026-06-19-00:00:
FN-6717 moves Nodes from a standalone overlay into a Command Center tab gated by the nodesView flag. Reuse NodesView unchanged so useNodes, managed Docker nodes, mesh state, settings sync, toast wiring, and data-testid anchors keep the same data flow.
*/
function useSubViews(): SubView[] {
function useSubViews(nodesEnabled: boolean): SubView[] {
const { t } = useTranslation("app");
return [
{ id: "overview", label: t("commandCenter.tabs.overview", "Overview") },
@@ -63,6 +69,7 @@ function useSubViews(): SubView[] {
{ id: "github", label: t("commandCenter.tabs.github", "GitHub") },
{ id: "signals", label: t("commandCenter.tabs.signals", "Signals") },
{ id: "system", label: t("commandCenter.tabs.system", "System") },
...(nodesEnabled ? [{ id: "nodes" as const, label: t("commandCenter.tabs.nodes", "Nodes") }] : []),
{ id: "reliability", label: t("commandCenter.tabs.reliability", "Reliability") },
{ id: "mission-control", label: t("commandCenter.tabs.missionControl", "Mission Control") },
];
@@ -93,6 +100,8 @@ interface CommandCenterProps {
themeMode?: ThemeMode;
onColorThemeChange?: (theme: ColorTheme) => void;
onThemeModeChange?: (mode: ThemeMode) => void;
addToast?: (message: string, type?: ToastType) => void;
nodesEnabled?: boolean;
}
function OverviewTab({
@@ -432,9 +441,11 @@ export function CommandCenter({
themeMode = "system",
onColorThemeChange = () => {},
onThemeModeChange = () => {},
addToast = () => {},
nodesEnabled = false,
}: CommandCenterProps = {}) {
const { t } = useTranslation("app");
const subViews = useSubViews();
const subViews = useSubViews(nodesEnabled);
const [activeTab, setActiveTab] = useState<SubViewId>("overview");
const [range, setRange] = useState<DateRange>(() => rangeFromPreset(defaultPresets((_k, f) => f)[1]));
@@ -514,6 +525,8 @@ export function CommandCenter({
return <SignalsArea range={range} />;
case "system":
return <SystemStatsArea />;
case "nodes":
return <NodesView addToast={addToast} />;
case "reliability":
return <ReliabilityView />;
case "mission-control":

View File

@@ -117,7 +117,7 @@ function populatedProductivityFixture() {
commits: 2,
pullRequests: 1,
loc: { value: 42, unavailable: false },
hoursSaved: { value: 1, unavailable: false },
hoursSaved: { value: 2.8, unavailable: false },
taskDuration: {
completedTasks: 2,
averageMs: 1_800_000,

View File

@@ -109,7 +109,7 @@ function populatedProductivityFixture() {
commits: 2,
pullRequests: 1,
loc: { value: 42, unavailable: false },
hoursSaved: { value: 2, unavailable: false },
hoursSaved: { value: 2.8, unavailable: false },
taskDuration: {
completedTasks: 2,
averageMs: 1_800_000,

View File

@@ -33,6 +33,16 @@ vi.mock("../../../api", () => ({
updateGlobalSettings: () => Promise.resolve({}),
}));
vi.mock("../../NodesView", () => ({
NodesView: ({ addToast }: { addToast: (message: string, type?: "success" | "error") => void }) => (
<section data-testid="nodes-view">
<button type="button" data-testid="nodes-view-toast-probe" onClick={() => addToast("Nodes tab toast", "success")}>
Nodes toast probe
</button>
</section>
),
}));
function tokenFixture(totalTokens = 1_500) {
return {
from: "2026-06-08",
@@ -716,6 +726,7 @@ describe("CommandCenter shell", () => {
const tabs = within(tablist).getAllByRole("tab");
// Overview, Tokens, Tools, Activity, Productivity, Team, Ecosystem, GitHub, Signals, System, Reliability, Mission Control.
expect(tabs.length).toBe(12);
expect(screen.queryByTestId("command-center-tab-nodes")).toBeNull();
// roving tabindex: exactly one tab is focusable.
const focusable = tabs.filter((tab) => tab.getAttribute("tabindex") === "0");
expect(focusable.length).toBe(1);
@@ -742,6 +753,25 @@ describe("CommandCenter shell", () => {
expect(screen.getByTestId("cc-system-cpu-gauge")).toBeTruthy();
});
it("renders and routes the Nodes tab when the nodes feature is enabled", () => {
const addToast = vi.fn();
render(<CommandCenter addToast={addToast} nodesEnabled={true} />);
expect(screen.getAllByTestId("command-center-tab-nodes")).toHaveLength(1);
fireEvent.click(screen.getByTestId("command-center-tab-nodes"));
expect(screen.getByTestId("command-center-tab-nodes").getAttribute("aria-selected")).toBe("true");
expect(screen.getByTestId("command-center-panel-nodes")).toBeTruthy();
expect(screen.getByTestId("nodes-view")).toBeTruthy();
fireEvent.click(screen.getByTestId("nodes-view-toast-probe"));
expect(addToast).toHaveBeenCalledWith("Nodes tab toast", "success");
});
it("omits the Nodes tab when the nodes feature is disabled", () => {
render(<CommandCenter nodesEnabled={false} />);
expect(screen.queryByTestId("command-center-tab-nodes")).toBeNull();
});
it("renders and routes the GitHub tab exactly once", async () => {
mockOverviewApi({ github: githubFixture(4, 2) });
render(<CommandCenter />);
@@ -858,7 +888,7 @@ describe("CommandCenter shell", () => {
});
it("keeps existing Command Center tab test ids after adding Team", () => {
render(<CommandCenter />);
render(<CommandCenter nodesEnabled={true} />);
for (const id of [
"overview",
"tokens",
@@ -869,6 +899,7 @@ describe("CommandCenter shell", () => {
"github",
"signals",
"system",
"nodes",
"reliability",
"mission-control",
"team",
@@ -878,7 +909,7 @@ describe("CommandCenter shell", () => {
});
it("supports arrow-key navigation between tabs (roving tabindex)", () => {
render(<CommandCenter />);
render(<CommandCenter nodesEnabled={true} />);
const overviewTab = screen.getByTestId("command-center-tab-overview");
overviewTab.focus();
fireEvent.keyDown(overviewTab, { key: "ArrowRight" });
@@ -889,6 +920,11 @@ describe("CommandCenter shell", () => {
const systemTab = screen.getByTestId("command-center-tab-system");
systemTab.focus();
fireEvent.keyDown(systemTab, { key: "ArrowRight" });
const nodesTab = screen.getByTestId("command-center-tab-nodes");
expect(nodesTab.getAttribute("aria-selected")).toBe("true");
expect(document.activeElement).toBe(nodesTab);
fireEvent.keyDown(nodesTab, { key: "ArrowRight" });
const reliabilityTab = screen.getByTestId("command-center-tab-reliability");
expect(reliabilityTab.getAttribute("aria-selected")).toBe("true");
expect(document.activeElement).toBe(reliabilityTab);

View File

@@ -3720,7 +3720,7 @@ input[type="range"]:focus-visible {
base rules (e.g. /\.mailbox-view\s*\{[^}]*\}/) don't pick this up as the
"first" definition for any individual selector. */
:is(.insights-view, .memory-view, .dev-server-view, .skills-view,
.documents-view, .roadmaps-view, .nodes-management-overlay,
.documents-view, .roadmaps-view,
.agents-view, .chat-view, .mission-manager, .mailbox-view,
.reliability-view) {
min-height: 0;

View File

@@ -7544,6 +7544,7 @@
"tools": "Tools",
"activity": "Activity",
"productivity": "Productivity",
"nodes": "Nodes",
"reliability": "Reliability",
"ecosystem": "Ecosystem",
"missionControl": "Mission Control"