From 48ff33e5158a9923f525fdf724ff9953c29fa74c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 19 Jul 2026 18:00:47 -0700 Subject: [PATCH] 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) --- .../fn-8398-compound-engineering-nav.md | 7 ++ .../app/components/__tests__/Header.test.tsx | 29 ++++++++ .../__tests__/LeftSidebarNav.test.tsx | 27 +++++++- .../__tests__/MobileNavBar.test.tsx | 21 ++++++ .../__tests__/usePluginDashboardViews.test.ts | 69 +++++++++++++++++++ .../app/hooks/usePluginDashboardViews.ts | 20 ++++++ .../src/__tests__/routes-system.test.ts | 44 ++++++++++++ .../src/routes/register-plugins-automation.ts | 13 +++- 8 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 .changeset/fn-8398-compound-engineering-nav.md 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); });