FN-8398: show enabled Compound Engineering navigation
Expose installed, enabled Compound Engineering views consistently across dashboard navigation. - Refresh plugin dashboard views when plugin lifecycle events arrive. - Scope dashboard-view discovery to the active project loader. - Cover desktop, header, mobile, hook, and route behavior. Files changed: .changeset/fn-8398-compound-engineering-nav.md | 7 +++ .../app/components/__tests__/Header.test.tsx | 29 +++++++++ .../components/__tests__/LeftSidebarNav.test.tsx | 27 ++++++++- .../app/components/__tests__/MobileNavBar.test.tsx | 21 +++++++ .../__tests__/usePluginDashboardViews.test.ts | 69 ++++++++++++++++++++++ .../dashboard/app/hooks/usePluginDashboardViews.ts | 20 +++++++ .../dashboard/src/__tests__/routes-system.test.ts | 44 ++++++++++++++ .../src/routes/register-plugins-automation.ts | 13 +++- 8 files changed, 225 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-8398 Fusion-Task-Lineage: ffcfe801-6fcf-47a9-857a-78668ecc7246 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8398-compound-engineering-nav.md
Normal file
7
.changeset/fn-8398-compound-engineering-nav.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Show Compound Engineering in navigation when the enabled plugin starts.
|
||||
category: fix
|
||||
dev: Refresh project-scoped dashboard views after plugin lifecycle events and prevent project view leakage.
|
||||
@@ -375,6 +375,35 @@ describe("Header", () => {
|
||||
expect(screen.getByTestId("view-toggle-plugin-fusion-plugin-roadmap-roadmaps")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Compound Engineering in sidebar-off header navigation and removes it after disable and uninstall", () => {
|
||||
const compoundEngineeringView = [{
|
||||
pluginId: "fusion-plugin-compound-engineering",
|
||||
view: {
|
||||
viewId: "compound-engineering",
|
||||
label: "Compound Engineering",
|
||||
componentPath: "./CompoundEngineeringView",
|
||||
icon: "Boxes",
|
||||
placement: "primary" as const,
|
||||
order: 36,
|
||||
},
|
||||
}];
|
||||
const rendered = renderHeader({
|
||||
onChangeView: noop,
|
||||
leftSidebarNavActive: false,
|
||||
pluginDashboardViews: compoundEngineeringView,
|
||||
});
|
||||
const testId = "view-toggle-plugin-fusion-plugin-compound-engineering-compound-engineering";
|
||||
|
||||
expect(screen.getByTestId(testId)).toBeInTheDocument();
|
||||
rendered.rerender(<Header onOpenSettings={noop} onOpenGitHubImport={noop} onChangeView={noop} leftSidebarNavActive={false} pluginDashboardViews={[]} />);
|
||||
expect(screen.queryByTestId(testId)).toBeNull();
|
||||
|
||||
rendered.rerender(<Header onOpenSettings={noop} onOpenGitHubImport={noop} onChangeView={noop} leftSidebarNavActive={false} pluginDashboardViews={compoundEngineeringView} />);
|
||||
expect(screen.getByTestId(testId)).toBeInTheDocument();
|
||||
rendered.rerender(<Header onOpenSettings={noop} onOpenGitHubImport={noop} onChangeView={noop} leftSidebarNavActive={false} pluginDashboardViews={[]} />);
|
||||
expect(screen.queryByTestId(testId)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders view overflow trigger when an experimental overflow feature is enabled", () => {
|
||||
renderHeader({ onChangeView: noop, experimentalFeatures: { insights: true } });
|
||||
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
|
||||
|
||||
@@ -426,13 +426,13 @@ describe("LeftSidebarNav", () => {
|
||||
});
|
||||
|
||||
it("renders plugin labels without view suffix and pins Compound Engineering to the Boxes sidebar icon", () => {
|
||||
renderSidebar({
|
||||
const rendered = renderSidebar({
|
||||
pluginDashboardViews: [
|
||||
...pluginViews,
|
||||
{
|
||||
pluginId: "fusion-plugin-compound-engineering",
|
||||
view: {
|
||||
viewId: "compound",
|
||||
viewId: "compound-engineering",
|
||||
label: "Compound Engineering",
|
||||
componentPath: "./CompoundEngineering",
|
||||
icon: "Sparkles",
|
||||
@@ -444,7 +444,7 @@ describe("LeftSidebarNav", () => {
|
||||
});
|
||||
|
||||
const primaryPlugin = screen.getByTestId("sidebar-nav-plugin-fusion-plugin-primary-primary-view");
|
||||
const compoundPlugin = screen.getByTestId("sidebar-nav-plugin-fusion-plugin-compound-engineering-compound");
|
||||
const compoundPlugin = screen.getByTestId("sidebar-nav-plugin-fusion-plugin-compound-engineering-compound-engineering");
|
||||
expect(primaryPlugin).toHaveAccessibleName("Primary Plugin");
|
||||
expect(primaryPlugin).toHaveAttribute("title", "Primary Plugin");
|
||||
expect(primaryPlugin).toHaveTextContent("Primary Plugin");
|
||||
@@ -456,6 +456,27 @@ describe("LeftSidebarNav", () => {
|
||||
expect(compoundPlugin.querySelector(".lucide-boxes")).not.toBeNull();
|
||||
expect(compoundPlugin.querySelector(".lucide-sparkles")).toBeNull();
|
||||
expect(compoundPlugin.querySelector(".lucide-grid-3x3")).toBeNull();
|
||||
|
||||
/*
|
||||
FNXC:CompoundEngineeringNav 2026-07-19-17:27:
|
||||
Disable and uninstall both remove the shared view entry; neither may leave a dead sidebar shell.
|
||||
*/
|
||||
rendered.rerender(<LeftSidebarNav {...rendered.props} pluginDashboardViews={[]} />);
|
||||
expect(screen.queryByTestId("sidebar-nav-plugin-fusion-plugin-compound-engineering-compound-engineering")).toBeNull();
|
||||
rendered.rerender(<LeftSidebarNav {...rendered.props} pluginDashboardViews={[...pluginViews, {
|
||||
pluginId: "fusion-plugin-compound-engineering",
|
||||
view: {
|
||||
viewId: "compound-engineering",
|
||||
label: "Compound Engineering",
|
||||
componentPath: "./CompoundEngineering",
|
||||
icon: "Sparkles",
|
||||
placement: "primary",
|
||||
order: 0,
|
||||
},
|
||||
}]} />);
|
||||
expect(screen.getByTestId("sidebar-nav-plugin-fusion-plugin-compound-engineering-compound-engineering")).toBeInTheDocument();
|
||||
rendered.rerender(<LeftSidebarNav {...rendered.props} pluginDashboardViews={[]} />);
|
||||
expect(screen.queryByTestId("sidebar-nav-plugin-fusion-plugin-compound-engineering-compound-engineering")).toBeNull();
|
||||
});
|
||||
|
||||
it.each<[TaskView, string]>([
|
||||
|
||||
@@ -782,6 +782,27 @@ describe("MobileNavBar", () => {
|
||||
expect(screen.queryByTestId("mobile-more-item-command-center")).toBeNull();
|
||||
});
|
||||
|
||||
it("removes Compound Engineering from More after disable and uninstall without creating a primary tab", () => {
|
||||
const props = createDefaultProps();
|
||||
const compoundEngineeringView = [{
|
||||
pluginId: "fusion-plugin-compound-engineering",
|
||||
view: { viewId: "compound-engineering", label: "Compound Engineering", componentPath: "./CompoundEngineeringView", icon: "Boxes", placement: "primary" as const, order: 36 },
|
||||
}];
|
||||
const testId = "mobile-more-item-plugin-fusion-plugin-compound-engineering-compound-engineering";
|
||||
const rendered = render(<MobileNavBar {...props} pluginDashboardViews={compoundEngineeringView} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||
expect(screen.getByTestId(testId)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("mobile-nav-tab-plugin-fusion-plugin-compound-engineering-compound-engineering")).toBeNull();
|
||||
|
||||
rendered.rerender(<MobileNavBar {...props} pluginDashboardViews={[]} />);
|
||||
expect(screen.queryByTestId(testId)).toBeNull();
|
||||
rendered.rerender(<MobileNavBar {...props} pluginDashboardViews={compoundEngineeringView} />);
|
||||
expect(screen.getByTestId(testId)).toBeInTheDocument();
|
||||
rendered.rerender(<MobileNavBar {...props} pluginDashboardViews={[]} />);
|
||||
expect(screen.queryByTestId(testId)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the hosted roadmaps plugin entry when roadmap plugin view is registered", () => {
|
||||
render(
|
||||
<MobileNavBar
|
||||
|
||||
@@ -2,17 +2,43 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { usePluginDashboardViews, __test_clearDashboardViewsCache } from "../usePluginDashboardViews";
|
||||
import * as api from "../../api";
|
||||
import { subscribeSse } from "../../sse-bus";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchPluginDashboardViews: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../sse-bus", () => ({
|
||||
subscribeSse: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(api.fetchPluginDashboardViews);
|
||||
const mockSubscribeSse = vi.mocked(subscribeSse);
|
||||
|
||||
const compoundEngineeringView = {
|
||||
pluginId: "fusion-plugin-compound-engineering",
|
||||
view: {
|
||||
viewId: "compound-engineering",
|
||||
label: "Compound Engineering",
|
||||
componentPath: "./dashboard-view",
|
||||
icon: "Boxes",
|
||||
placement: "primary" as const,
|
||||
order: 36,
|
||||
},
|
||||
};
|
||||
|
||||
function emitPluginLifecycle(transition: string): void {
|
||||
const subscription = mockSubscribeSse.mock.calls.at(-1)?.[1];
|
||||
const handler = subscription?.events?.["plugin:lifecycle"];
|
||||
if (!handler) throw new Error("expected plugin:lifecycle subscription");
|
||||
handler(new MessageEvent("plugin:lifecycle", { data: JSON.stringify({ transition }) }));
|
||||
}
|
||||
|
||||
describe("usePluginDashboardViews", () => {
|
||||
beforeEach(() => {
|
||||
__test_clearDashboardViewsCache();
|
||||
mockFetch.mockReset();
|
||||
mockSubscribeSse.mockClear();
|
||||
});
|
||||
|
||||
it("returns empty array when no dashboard views are registered", async () => {
|
||||
@@ -102,6 +128,49 @@ describe("usePluginDashboardViews", () => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("project-b");
|
||||
});
|
||||
|
||||
it("refreshes the current project's cached views across install, disable, and uninstall lifecycle transitions", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([compoundEngineeringView])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([compoundEngineeringView])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const { result } = renderHook(() => usePluginDashboardViews("project-a"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.views).toEqual([]);
|
||||
|
||||
emitPluginLifecycle("registered");
|
||||
await waitFor(() => expect(result.current.views).toEqual([compoundEngineeringView]));
|
||||
|
||||
emitPluginLifecycle("disabled");
|
||||
await waitFor(() => expect(result.current.views).toEqual([]));
|
||||
|
||||
emitPluginLifecycle("enabled");
|
||||
await waitFor(() => expect(result.current.views).toEqual([compoundEngineeringView]));
|
||||
|
||||
emitPluginLifecycle("uninstalled");
|
||||
await waitFor(() => expect(result.current.views).toEqual([]));
|
||||
expect(mockFetch).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it("never exposes project A's Compound Engineering view after switching to project B", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce([compoundEngineeringView])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }: { projectId: string }) => usePluginDashboardViews(projectId),
|
||||
{ initialProps: { projectId: "project-a" } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.views).toEqual([compoundEngineeringView]));
|
||||
|
||||
rerender({ projectId: "project-b" });
|
||||
await waitFor(() => expect(result.current.views).toEqual([]));
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(1, "project-a");
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(2, "project-b");
|
||||
});
|
||||
|
||||
it("supports filtering view entries by pluginId in consumers", async () => {
|
||||
mockFetch.mockResolvedValueOnce([
|
||||
{ pluginId: "fusion-plugin-dependency-graph", view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" } },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { fetchPluginDashboardViews } from "../api";
|
||||
import type { PluginDashboardViewEntry } from "../api";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
|
||||
const dashboardViewsCache = new Map<string, { views: PluginDashboardViewEntry[]; expiresAt: number }>();
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
@@ -32,6 +33,25 @@ export function usePluginDashboardViews(projectId?: string): {
|
||||
setReloadKey((key) => key + 1);
|
||||
}, [projectId]);
|
||||
|
||||
/*
|
||||
FNXC:CompoundEngineeringNav 2026-07-19-17:01:
|
||||
An enabled plugin becomes a navigation destination only after its project-scoped loader starts it.
|
||||
Subscribe at the shared dashboard-views seam so install, enable, disable, and uninstall lifecycle
|
||||
changes invalidate every nav renderer together instead of leaving a 60-second stale empty sidebar.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (projectId) params.set("projectId", projectId);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
|
||||
const refreshViews = () => refetch();
|
||||
return subscribeSse(`/api/events${query}`, {
|
||||
events: { "plugin:lifecycle": refreshViews },
|
||||
// A reconnect may have missed a lifecycle event, so re-read the loaded set.
|
||||
onReconnect: refreshViews,
|
||||
});
|
||||
}, [projectId, refetch]);
|
||||
|
||||
useEffect(() => {
|
||||
const cacheKey = projectId ?? "default";
|
||||
let cancelled = false;
|
||||
|
||||
@@ -819,6 +819,50 @@ describe("POST /api/kill-vitest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/plugins/dashboard-views", () => {
|
||||
it("uses the request project's loaded plugin loader rather than leaking launch-project views", async () => {
|
||||
const compoundEngineeringView = {
|
||||
pluginId: "fusion-plugin-compound-engineering",
|
||||
view: {
|
||||
viewId: "compound-engineering",
|
||||
label: "Compound Engineering",
|
||||
componentPath: "./dashboard-view",
|
||||
icon: "Boxes",
|
||||
placement: "primary",
|
||||
order: 36,
|
||||
},
|
||||
};
|
||||
const projectLoaders = {
|
||||
"project-a": { getPluginDashboardViews: vi.fn().mockResolvedValue([compoundEngineeringView]) },
|
||||
"project-b": { getPluginDashboardViews: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
const engines = Object.fromEntries(
|
||||
Object.entries(projectLoaders).map(([projectId, loader]) => [projectId, {
|
||||
getTaskStore: () => createMockStore(),
|
||||
getPluginRunner: () => ({ getLoader: () => loader }),
|
||||
}]),
|
||||
);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(createMockStore(), {
|
||||
// The launch loader intentionally has CE to reproduce the prior project-A leak.
|
||||
pluginLoader: projectLoaders["project-a"],
|
||||
engineManager: {
|
||||
getEngine: (projectId: string) => engines[projectId as keyof typeof engines],
|
||||
onProjectAccessed: vi.fn(),
|
||||
} as any,
|
||||
}));
|
||||
|
||||
const [projectA, projectB] = await Promise.all([
|
||||
GET(app, "/api/plugins/dashboard-views?projectId=project-a"),
|
||||
GET(app, "/api/plugins/dashboard-views?projectId=project-b"),
|
||||
]);
|
||||
|
||||
expect(projectA.body).toEqual([compoundEngineeringView]);
|
||||
expect(projectB.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/plugins/runtimes", () => {
|
||||
function buildApp(pluginLoader?: { getPluginRuntimes?: () => Array<{ pluginId: string; runtime: { metadata: { runtimeId: string; name: string; description?: string; version?: string }; factory: () => unknown } }> }) {
|
||||
const app = express();
|
||||
|
||||
@@ -908,8 +908,17 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu
|
||||
* Get all plugin top-level dashboard view definitions from active plugins.
|
||||
* Returns aggregated array of { pluginId, view } objects.
|
||||
*/
|
||||
router.get("/plugins/dashboard-views", async (_req: Request, res: Response) => {
|
||||
const views = await options?.pluginLoader?.getPluginDashboardViews() ?? [];
|
||||
/*
|
||||
FNXC:PluginNavigation 2026-07-19-17:01:
|
||||
Dashboard views are a loaded-plugin contract scoped to the requested project. Never use the
|
||||
launch project's fallback loader for an explicit project id: doing so leaks enabled Compound
|
||||
Engineering navigation from project A into project B while B's loader is still absent or empty.
|
||||
*/
|
||||
router.get("/plugins/dashboard-views", async (req: Request, res: Response) => {
|
||||
const { engine, projectId } = await getProjectContext(req);
|
||||
const pluginLoader = engine?.getPluginRunner?.()?.getLoader()
|
||||
?? (projectId === undefined ? options?.pluginLoader : undefined);
|
||||
const views = await pluginLoader?.getPluginDashboardViews() ?? [];
|
||||
res.json(views);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user