diff --git a/.changeset/fn-8398-compound-engineering-nav.md b/.changeset/fn-8398-compound-engineering-nav.md
new file mode 100644
index 0000000000..50c214ef35
--- /dev/null
+++ b/.changeset/fn-8398-compound-engineering-nav.md
@@ -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.
diff --git a/packages/dashboard/app/components/__tests__/Header.test.tsx b/packages/dashboard/app/components/__tests__/Header.test.tsx
index 7c6470b5b3..a63f7cd5d4 100644
--- a/packages/dashboard/app/components/__tests__/Header.test.tsx
+++ b/packages/dashboard/app/components/__tests__/Header.test.tsx
@@ -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();
+ expect(screen.queryByTestId(testId)).toBeNull();
+
+ rendered.rerender();
+ expect(screen.getByTestId(testId)).toBeInTheDocument();
+ rendered.rerender();
+ 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();
diff --git a/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx b/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx
index 7df527ed7a..293a2a0f76 100644
--- a/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx
+++ b/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx
@@ -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();
+ expect(screen.queryByTestId("sidebar-nav-plugin-fusion-plugin-compound-engineering-compound-engineering")).toBeNull();
+ rendered.rerender();
+ expect(screen.getByTestId("sidebar-nav-plugin-fusion-plugin-compound-engineering-compound-engineering")).toBeInTheDocument();
+ rendered.rerender();
+ expect(screen.queryByTestId("sidebar-nav-plugin-fusion-plugin-compound-engineering-compound-engineering")).toBeNull();
});
it.each<[TaskView, string]>([
diff --git a/packages/dashboard/app/components/__tests__/MobileNavBar.test.tsx b/packages/dashboard/app/components/__tests__/MobileNavBar.test.tsx
index 409c219bdc..337a3a9a2b 100644
--- a/packages/dashboard/app/components/__tests__/MobileNavBar.test.tsx
+++ b/packages/dashboard/app/components/__tests__/MobileNavBar.test.tsx
@@ -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();
+
+ 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();
+ expect(screen.queryByTestId(testId)).toBeNull();
+ rendered.rerender();
+ expect(screen.getByTestId(testId)).toBeInTheDocument();
+ rendered.rerender();
+ expect(screen.queryByTestId(testId)).toBeNull();
+ });
+
it("renders the hosted roadmaps plugin entry when roadmap plugin view is registered", () => {
render(
({
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" } },
diff --git a/packages/dashboard/app/hooks/usePluginDashboardViews.ts b/packages/dashboard/app/hooks/usePluginDashboardViews.ts
index 4c4b5c7b35..2efe7bbd4e 100644
--- a/packages/dashboard/app/hooks/usePluginDashboardViews.ts
+++ b/packages/dashboard/app/hooks/usePluginDashboardViews.ts
@@ -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();
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;
diff --git a/packages/dashboard/src/__tests__/routes-system.test.ts b/packages/dashboard/src/__tests__/routes-system.test.ts
index cea127d976..c5fda1eaa8 100644
--- a/packages/dashboard/src/__tests__/routes-system.test.ts
+++ b/packages/dashboard/src/__tests__/routes-system.test.ts
@@ -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();
diff --git a/packages/dashboard/src/routes/register-plugins-automation.ts b/packages/dashboard/src/routes/register-plugins-automation.ts
index 99fbb3ce1a..c1e6a4df62 100644
--- a/packages/dashboard/src/routes/register-plugins-automation.ts
+++ b/packages/dashboard/src/routes/register-plugins-automation.ts
@@ -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);
});