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:
gsxdsm
2026-07-19 18:00:47 -07:00
parent fc24e66f63
commit 48ff33e515
8 changed files with 225 additions and 5 deletions

View 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.

View File

@@ -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();

View File

@@ -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]>([

View File

@@ -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

View File

@@ -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" } },

View File

@@ -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;

View File

@@ -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();

View File

@@ -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);
});