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:
@@ -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`
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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`");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: [] }));
|
||||
|
||||
|
||||
@@ -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={{}}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7544,6 +7544,7 @@
|
||||
"tools": "Tools",
|
||||
"activity": "Activity",
|
||||
"productivity": "Productivity",
|
||||
"nodes": "Nodes",
|
||||
"reliability": "Reliability",
|
||||
"ecosystem": "Ecosystem",
|
||||
"missionControl": "Mission Control"
|
||||
|
||||
Reference in New Issue
Block a user