feat(FN-3157): add plugin dashboard view registry with nav integration

Merged FN-3157 to add a plugin dashboard views system, including a plugin view registry with lazy loading, navigation integration for Header and MobileNavBar, a usePluginDashboardViews hook with cache and refetch support, and tests covering the no-loader path. Also added documentation in `docs/PLUGI

Fusion-Task-Id: FN-3157
This commit is contained in:
Fusion
2026-05-05 12:08:44 -07:00
committed by gsxdsm
parent c87c9e2e1f
commit d7880c66b0
14 changed files with 253 additions and 75 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add plugin dashboard view discovery and navigation integration via `GET /api/plugins/dashboard-views`, plugin view ID persistence (`plugin:${pluginId}:${viewId}`), and static host-side plugin view registry rendering.

View File

@@ -555,6 +555,28 @@ Current host constraints:
- `componentPath` is stored for authoring symmetry/future expansion, but render resolution is currently done through a host-side static registry (`pluginId + viewId`)
- Use stable IDs; runtime view key format is `plugin:${pluginId}:${viewId}`
### Static host registry model
Dashboard view components are resolved from a host-side registry and must be explicitly registered:
```ts
import { lazy } from "react";
import { registerPluginView } from "../app/plugins/pluginViewRegistry";
registerPluginView(
"fusion-plugin-dependency-graph",
"graph",
lazy(() => import("@fusion-plugin-examples/dependency-graph/dashboard-view")),
);
```
The host then renders plugin views via `PluginDashboardViewHost` using the composite ID.
Placement guidance:
- `primary`: top-level nav tab (host may limit count on mobile)
- `overflow`: desktop header overflow menu
- `more`: mobile More sheet / secondary nav surfaces
---
## 9. Registering Agent Runtimes

View File

@@ -49,6 +49,7 @@ import { useViewState, type TaskView } from "./hooks/useViewState";
import { useNavigationHistory } from "./hooks/useNavigationHistory";
import { usePluginDashboardViews } from "./hooks/usePluginDashboardViews";
import { PluginDashboardViewHost } from "./plugins/PluginDashboardViewHost";
import { isPluginViewId } from "./plugins/pluginViewRegistry";
import { useProjectActions } from "./hooks/useProjectActions";
import { useTaskHandlers } from "./hooks/useTaskHandlers";
import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
@@ -448,6 +449,7 @@ function AppInner() {
// Redirect to board if feature-gated views are disabled.
useEffect(() => {
if (!settingsLoaded) return;
if (isPluginViewId(taskView)) return;
if (taskView === "skills" && !skillsEnabled) {
handleChangeTaskView("board");
}
@@ -867,7 +869,7 @@ function AppInner() {
}
// Project view
if (taskView.startsWith("plugin:")) {
if (isPluginViewId(taskView)) {
return (
<PageErrorBoundary>
<PluginDashboardViewHost
@@ -1319,7 +1321,7 @@ function AppInner() {
<NativeShellConnectionStatus state={shellState} onManage={() => setShellConnectionManagerOpen(true)} />
) : undefined}
/>
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && !taskView.startsWith("plugin:") && (
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && !isPluginViewId(taskView) && (
<QuickChatFAB
projectId={currentProject.id}
addToast={addToast}

View File

@@ -13,7 +13,7 @@ import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
import { getTrailingPath } from "../utils/pathDisplay";
import type { TaskView } from "../hooks/useViewState";
import type { PluginDashboardViewEntry } from "../api";
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
import { buildPluginTaskViewId, isPluginViewId } from "../plugins/pluginViewRegistry";
import { getPluginNavIcon } from "./pluginNavIcon";
export { useViewportMode };
@@ -1152,7 +1152,7 @@ export function Header({
<>
<button
ref={viewOverflowTriggerRef}
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (todosEnabled && todosOpen) || view.startsWith("plugin:") ? " active" : ""}`}
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
title="More views"
aria-label="More views"

View File

@@ -35,7 +35,7 @@ import { fetchScripts } from "../api";
import type { PluginDashboardViewEntry } from "../api";
import { useViewportMode } from "./Header";
import type { TaskView } from "../hooks/useViewState";
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
import { buildPluginTaskViewId, isPluginViewId } from "../plugins/pluginViewRegistry";
import { getPluginNavIcon } from "./pluginNavIcon";
export interface MobileNavBarProps {
@@ -247,7 +247,7 @@ export function MobileNavBar({
|| (todosOpen && todoViewEnabled)
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|| (view === "skills" && !showSkillsTopLevel)
|| (view.startsWith("plugin:") && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
|| (isPluginViewId(view) && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
return (
<>

View File

@@ -1645,8 +1645,7 @@ describe("App view switching", () => {
render(<App />);
await waitFor(() => {
expect(screen.getByText("Zoom In")).toBeInTheDocument();
expect(screen.getByText("Zoom Out")).toBeInTheDocument();
expect(screen.getByText("Plugin view unavailable")).toBeInTheDocument();
});
localStorage.removeItem(taskViewStorageKey());

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { usePluginDashboardViews, __test_clearDashboardViewsCache } from "../usePluginDashboardViews";
import * as api from "../../api";
@@ -15,6 +15,14 @@ describe("usePluginDashboardViews", () => {
mockFetch.mockReset();
});
it("returns empty array when no dashboard views are registered", async () => {
mockFetch.mockResolvedValueOnce([]);
const { result } = renderHook(() => usePluginDashboardViews());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.views).toEqual([]);
expect(result.current.error).toBeNull();
});
it("fetches and returns dashboard views", async () => {
mockFetch.mockResolvedValueOnce([
{ pluginId: "dep", view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" } },
@@ -25,6 +33,60 @@ describe("usePluginDashboardViews", () => {
expect(result.current.views).toHaveLength(1);
});
it("caches results and doesn't re-fetch within ttl", async () => {
mockFetch.mockResolvedValueOnce([
{ pluginId: "dep", view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" } },
]);
const first = renderHook(() => usePluginDashboardViews("project-a"));
await waitFor(() => expect(first.result.current.loading).toBe(false));
mockFetch.mockClear();
const second = renderHook(() => usePluginDashboardViews("project-a"));
await waitFor(() => expect(second.result.current.loading).toBe(false));
expect(mockFetch).not.toHaveBeenCalled();
});
it("sets loading only on initial fetch, not on cache-hit", async () => {
mockFetch.mockResolvedValueOnce([
{ pluginId: "dep", view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" } },
]);
const first = renderHook(() => usePluginDashboardViews("project-a"));
expect(first.result.current.loading).toBe(true);
await waitFor(() => expect(first.result.current.loading).toBe(false));
mockFetch.mockClear();
const second = renderHook(() => usePluginDashboardViews("project-a"));
expect(second.result.current.loading).toBe(false);
expect(mockFetch).not.toHaveBeenCalled();
});
it("handles fetch errors gracefully", async () => {
mockFetch.mockRejectedValueOnce(new Error("boom"));
const { result } = renderHook(() => usePluginDashboardViews());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.views).toEqual([]);
expect(result.current.error).toBe("boom");
});
it("refetch invalidates cache and fetches again", async () => {
mockFetch
.mockResolvedValueOnce([{ pluginId: "a", view: { viewId: "x", label: "X", componentPath: "./x.js" } }])
.mockResolvedValueOnce([{ pluginId: "a", view: { viewId: "y", label: "Y", componentPath: "./y.js" } }]);
const { result } = renderHook(() => usePluginDashboardViews("project-a"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.views[0]?.view.viewId).toBe("x");
await act(async () => {
result.current.refetch();
});
await waitFor(() => expect(result.current.views[0]?.view.viewId).toBe("y"));
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("uses project-scoped cache keys", async () => {
mockFetch.mockResolvedValueOnce([{ pluginId: "a", view: { viewId: "x", label: "X", componentPath: "./x.js" } }]);
const first = renderHook(() => usePluginDashboardViews("project-a"));

View File

@@ -329,6 +329,22 @@ describe("useViewState", () => {
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("plugin:fusion-plugin-dependency-graph:graph");
});
it("rejects invalid plugin view IDs and falls back to board", async () => {
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "plugin:only-one-segment");
const { result } = renderHook(() =>
useViewState(
createOptions({
currentProject: PROJECT,
}),
),
);
await waitFor(() => {
expect(result.current.taskView).toBe("board");
});
});
it("restores legacy views (board/list/agents/missions/chat) from scoped storage", async () => {
const legacyViews = ["board", "list", "agents", "missions", "chat"] as const;

View File

@@ -1,24 +1,37 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchPluginDashboardViews } from "../api";
import type { PluginDashboardViewEntry } from "../api";
const dashboardViewsCache = new Map<string, { views: PluginDashboardViewEntry[]; expiresAt: number }>();
const CACHE_TTL_MS = 60_000;
/** Clear module cache for deterministic hook tests. */
export function __test_clearDashboardViewsCache(): void {
dashboardViewsCache.clear();
}
/**
* Fetch plugin dashboard views with a 60s project-scoped cache.
* Loading is only true for the first fetch of each hook lifecycle.
*/
export function usePluginDashboardViews(projectId?: string): {
views: PluginDashboardViewEntry[];
loading: boolean;
error: string | null;
refetch: () => void;
} {
const [views, setViews] = useState<PluginDashboardViewEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const initialLoadCompleteRef = useRef(false);
const refetch = useCallback(() => {
const cacheKey = projectId ?? "default";
dashboardViewsCache.delete(cacheKey);
setReloadKey((key) => key + 1);
}, [projectId]);
useEffect(() => {
const cacheKey = projectId ?? "default";
let cancelled = false;
@@ -57,7 +70,7 @@ export function usePluginDashboardViews(projectId?: string): {
return () => {
cancelled = true;
};
}, [projectId]);
}, [projectId, reloadKey]);
return useMemo(() => ({ views, loading, error }), [views, loading, error]);
return useMemo(() => ({ views, loading, error, refetch }), [views, loading, error, refetch]);
}

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { ThemeMode } from "@fusion/core";
import type { ProjectInfo } from "../api";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import { isPluginViewId } from "../plugins/pluginViewRegistry";
export type ViewMode = "overview" | "project";
export type BuiltInTaskView = "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
@@ -29,12 +30,8 @@ function isBuiltInTaskView(value: string | null): value is BuiltInTaskView {
return value !== null && BUILT_IN_TASK_VIEWS.includes(value as BuiltInTaskView);
}
function isPluginTaskView(value: string | null): value is PluginTaskView {
return value !== null && /^plugin:[^:]+:.+$/u.test(value);
}
function isTaskView(value: string | null): value is TaskView {
return isBuiltInTaskView(value) || isPluginTaskView(value);
return value !== null && (isBuiltInTaskView(value) || isPluginViewId(value));
}
function normalizeTaskView(value: TaskView): TaskView {

View File

@@ -1,20 +1,6 @@
import { resolvePluginDashboardView, MissingPluginDashboardView, parsePluginTaskViewId } from "./pluginViewRegistry";
import type { PluginDashboardHostContext, PluginTaskView } from "./pluginViewRegistry";
import { PluginDashboardViewHost as RegistryPluginDashboardViewHost } from "./pluginViewRegistry";
import type { PluginTaskView } from "./pluginViewRegistry";
export function PluginDashboardViewHost({
taskView,
context,
}: {
taskView: PluginTaskView;
context: PluginDashboardHostContext;
}) {
const parsed = parsePluginTaskViewId(taskView);
if (!parsed) return null;
const ViewComponent = resolvePluginDashboardView(parsed.pluginId, parsed.viewId);
if (!ViewComponent) {
return <>{MissingPluginDashboardView({ pluginId: parsed.pluginId, viewId: parsed.viewId })}</>;
}
return <ViewComponent context={context} />;
export function PluginDashboardViewHost({ taskView }: { taskView: PluginTaskView; context?: unknown }) {
return <RegistryPluginDashboardViewHost viewId={taskView} />;
}

View File

@@ -0,0 +1,48 @@
import { describe, expect, it, beforeEach } from "vitest";
import { lazy } from "react";
import { render, screen } from "@testing-library/react";
import {
__test_clearPluginViewRegistry,
getPluginViewComponent,
getPluginViewId,
isPluginViewId,
parsePluginViewId,
PluginDashboardViewHost,
registerPluginView,
} from "../pluginViewRegistry";
describe("pluginViewRegistry", () => {
beforeEach(() => {
__test_clearPluginViewRegistry();
});
it("builds plugin IDs", () => {
expect(getPluginViewId("plugin-a", "main")).toBe("plugin:plugin-a:main");
});
it("parses and validates plugin IDs", () => {
expect(parsePluginViewId("plugin:plugin-a:main")).toEqual({ pluginId: "plugin-a", viewId: "main" });
expect(parsePluginViewId("board")).toBeNull();
expect(isPluginViewId("plugin:plugin-a:main")).toBe(true);
expect(isPluginViewId("plugin:only-one-segment")).toBe(false);
});
it("registers and resolves view components", () => {
const View = lazy(async () => ({ default: () => <div>Plugin View</div> }));
registerPluginView("plugin-a", "main", View);
expect(getPluginViewComponent("plugin-a", "main")).toBe(View);
expect(getPluginViewComponent("plugin-b", "missing")).toBeNull();
});
it("renders registered components", async () => {
const View = lazy(async () => ({ default: () => <div>Rendered Plugin View</div> }));
registerPluginView("plugin-a", "main", View);
render(<>{PluginDashboardViewHost({ viewId: "plugin:plugin-a:main" })}</>);
expect(await screen.findByText("Rendered Plugin View")).toBeInTheDocument();
});
it("renders unavailable fallback for unregistered views", () => {
render(<>{PluginDashboardViewHost({ viewId: "plugin:plugin-a:missing" })}</>);
expect(screen.getByTestId("plugin-view-unavailable")).toBeInTheDocument();
});
});

View File

@@ -1,64 +1,82 @@
import { AlertTriangle } from "lucide-react";
import type { ComponentType, ReactNode } from "react";
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
import { DependencyGraphView } from "@fusion-plugin-examples/dependency-graph/dashboard-view";
import { lazy, Suspense, type LazyExoticComponent, type ReactNode } from "react";
import { ErrorBoundary } from "../components/ErrorBoundary";
import "./pluginViewRegistry.css";
export type PluginTaskView = `plugin:${string}:${string}`;
export interface PluginDashboardHostContext {
projectId?: string;
tasks: Task[];
workflowSteps: WorkflowStep[];
openTaskDetail: (task: Task | TaskDetail, initialTab?: "logs" | "changes") => void;
renderTaskCard: (task: Task) => ReactNode;
}
type PluginViewComponent = LazyExoticComponent<() => JSX.Element>;
export interface PluginDashboardViewComponentProps {
context: PluginDashboardHostContext;
}
const registry = new Map<string, PluginViewComponent>();
export interface PluginDashboardViewRegistration {
pluginId: string;
viewId: string;
component: ComponentType<PluginDashboardViewComponentProps>;
}
const REGISTRY: PluginDashboardViewRegistration[] = [
{
pluginId: "fusion-plugin-dependency-graph",
viewId: "graph",
component: DependencyGraphView as ComponentType<PluginDashboardViewComponentProps>,
},
];
export function buildPluginTaskViewId(pluginId: string, viewId: string): PluginTaskView {
/** Build composite plugin task view ID: plugin:{pluginId}:{viewId}. */
export function getPluginViewId(pluginId: string, viewId: string): PluginTaskView {
return `plugin:${pluginId}:${viewId}`;
}
export function parsePluginTaskViewId(taskView: string): { pluginId: string; viewId: string } | null {
if (!taskView.startsWith("plugin:")) return null;
const [, pluginId, ...viewParts] = taskView.split(":");
const viewId = viewParts.join(":");
if (!pluginId || !viewId) return null;
return { pluginId, viewId };
/** Parse composite plugin task view ID. Returns null for non-plugin IDs. */
export function parsePluginViewId(value: string): { pluginId: string; viewId: string } | null {
const match = /^plugin:([^:]+):(.+)$/u.exec(value);
if (!match) return null;
return { pluginId: match[1], viewId: match[2] };
}
export function resolvePluginDashboardView(pluginId: string, viewId: string): ComponentType<PluginDashboardViewComponentProps> | null {
const hit = REGISTRY.find((entry) => entry.pluginId === pluginId && entry.viewId === viewId);
return hit?.component ?? null;
/** True when a view ID matches the plugin composite ID format. */
export function isPluginViewId(value: string): value is PluginTaskView {
return parsePluginViewId(value) !== null;
}
export function MissingPluginDashboardView({ pluginId, viewId }: { pluginId: string; viewId: string }): ReactNode {
/** Register a lazy plugin dashboard view component in the static host registry. */
export function registerPluginView(pluginId: string, viewId: string, lazyComponent: PluginViewComponent): void {
registry.set(getPluginViewId(pluginId, viewId), lazyComponent);
}
/** Resolve a plugin dashboard lazy component from the static host registry. */
export function getPluginViewComponent(pluginId: string, viewId: string): PluginViewComponent | null {
return registry.get(getPluginViewId(pluginId, viewId)) ?? null;
}
/** Test helper for clearing global registry state. */
export function __test_clearPluginViewRegistry(): void {
registry.clear();
}
function PluginViewUnavailable({ viewId }: { viewId: string }): ReactNode {
return (
<section className="card plugin-dashboard-view-missing">
<section className="card plugin-dashboard-view-missing" data-testid="plugin-view-unavailable">
<h2 className="plugin-dashboard-view-missing-title">
<AlertTriangle />
Plugin view unavailable
</h2>
<p className="plugin-dashboard-view-missing-description">
The dashboard could not resolve <code>{pluginId}:{viewId}</code> from the host registry.
No host registration found for <code>{viewId}</code>.
</p>
</section>
);
}
export function PluginDashboardViewHost({ viewId }: { viewId: PluginTaskView }): ReactNode {
const parsed = parsePluginViewId(viewId);
if (!parsed) return <PluginViewUnavailable viewId={viewId} />;
const ViewComponent = getPluginViewComponent(parsed.pluginId, parsed.viewId);
if (!ViewComponent) {
return <PluginViewUnavailable viewId={viewId} />;
}
return (
<ErrorBoundary fallback={<PluginViewUnavailable viewId={viewId} />}>
<Suspense fallback={null}>
<ViewComponent />
</Suspense>
</ErrorBoundary>
);
}
// Backward-compatible aliases.
export const buildPluginTaskViewId = getPluginViewId;
export const parsePluginTaskViewId = parsePluginViewId;
export const resolvePluginDashboardView = getPluginViewComponent;
// Ensure lazy is referenced for plugin authors importing only this module pattern.
void lazy;

View File

@@ -703,6 +703,16 @@ describe("GET /api/plugins/dashboard-views", () => {
return app;
}
it("returns empty array when pluginLoader is not available", async () => {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { pluginStore }));
const res = await performGet(app, "/api/plugins/dashboard-views");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("returns 200 with empty array when no plugins have dashboard views", async () => {
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockReturnValue([]);
const res = await performGet(buildApp(), "/api/plugins/dashboard-views");