feat(FN-3079): add plugin dashboard views and dependency graph plugin

- Add plugin dashboard view registration and hosting across core, dashboard routes, and plugin SDK exports
- Integrate plugin-provided views into app navigation, mobile/header UI, and view state hooks with coverage
- Add fusion-plugin-dependency-graph example plugin with persisted storage, dashboard view UI, and manifest wiring
- Update plugin authoring and architecture docs for dashboard view extension points
- Add a changeset for @runfusion/fusion covering plugin dashboard view support

Fusion-Task-Id: FN-3079
This commit is contained in:
Fusion
2026-05-01 23:32:32 -07:00
committed by gsxdsm
parent a284138167
commit 0597b34eea
38 changed files with 1264 additions and 16 deletions

View File

@@ -1288,6 +1288,42 @@ describe("PluginLoader", () => {
});
});
describe("getPluginDashboardViews", () => {
it("returns empty array when no plugins loaded", async () => {
await pluginStore.init();
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
expect(loader.getPluginDashboardViews()).toEqual([]);
});
it("aggregates dashboard views from multiple plugins and keeps uiSlots separate", async () => {
await pluginStore.init();
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
(loader as any).plugins.set("views-a", {
manifest: makeManifest({ id: "views-a" }),
state: "started",
hooks: {},
uiSlots: [{ slotId: "task-detail-tab", label: "Tab", componentPath: "./tab.js" }],
dashboardViews: [{ viewId: "graph", label: "Graph", componentPath: "./graph.js", placement: "more" }],
} as FusionPlugin);
(loader as any).plugins.set("views-b", {
manifest: makeManifest({ id: "views-b" }),
state: "started",
hooks: {},
dashboardViews: [{ viewId: "timeline", label: "Timeline", componentPath: "./timeline.js" }],
} as FusionPlugin);
const views = loader.getPluginDashboardViews();
expect(views).toHaveLength(2);
expect(views.map((entry) => entry.pluginId + ":" + entry.view.viewId)).toEqual([
"views-a:graph",
"views-b:timeline",
]);
expect(loader.getPluginUiSlots()).toHaveLength(1);
});
});
// ── getPluginRuntimes ─────────────────────────────────────────────
describe("getPluginRuntimes", () => {

View File

@@ -140,6 +140,7 @@ export type {
PluginRouteDefinition,
PluginRouteMethod,
PluginUiSlotDefinition,
PluginDashboardViewDefinition,
PluginRuntimeManifestMetadata,
PluginRuntimeFactory,
PluginRuntimeRegistration,

View File

@@ -22,6 +22,7 @@ import type {
PluginToolDefinition,
PluginRouteDefinition,
PluginUiSlotDefinition,
PluginDashboardViewDefinition,
PluginRuntimeRegistration,
PluginInstallation,
PluginSkillContribution,
@@ -775,6 +776,22 @@ export class PluginLoader extends EventEmitter<{
return slots;
}
/**
* Get all top-level dashboard view definitions from loaded plugins.
*/
getPluginDashboardViews(): Array<{ pluginId: string; view: PluginDashboardViewDefinition }> {
const views: Array<{ pluginId: string; view: PluginDashboardViewDefinition }> = [];
for (const [pluginId, plugin] of this.plugins) {
if (plugin.dashboardViews) {
for (const view of plugin.dashboardViews) {
views.push({ pluginId, view });
}
}
}
return views;
}
/**
* Get all runtime registrations from loaded plugins.
* Returns plugin ownership metadata along with the runtime registration.

View File

@@ -179,6 +179,28 @@ export interface PluginUiSlotDefinition {
componentPath: string;
}
/**
* Top-level dashboard view definition for plugin-provided navigation destinations.
* This is separate from embedded uiSlots and is rendered via host-managed registry.
*/
export interface PluginDashboardViewDefinition {
/** Unique view identifier within a plugin namespace. */
viewId: string;
/** Human-readable label shown in dashboard navigation. */
label: string;
/**
* Path to module exporting the dashboard view component.
* Stored for authoring symmetry/future expansion; host currently resolves via static registry.
*/
componentPath: string;
/** Optional icon name (lucide-react icon name or custom icon identifier). */
icon?: string;
/** Optional sort order for nav presentation. Lower numbers appear first. */
order?: number;
/** Preferred navigation placement for this top-level view. */
placement?: "primary" | "more";
}
// ── Plugin Runtimes ─────────────────────────────────────────────────
/**
@@ -356,6 +378,8 @@ export interface FusionPlugin {
tools?: PluginToolDefinition[];
routes?: PluginRouteDefinition[];
uiSlots?: PluginUiSlotDefinition[];
/** Plugin-contributed top-level dashboard views. */
dashboardViews?: PluginDashboardViewDefinition[];
/** Agent runtime registration for providing custom runtime implementations */
runtime?: PluginRuntimeRegistration;
/** Plugin-contributed skills surfaced by the skill resolver. */

View File

@@ -68,6 +68,19 @@ The dashboard header adapts across three responsive tiers to remain usable witho
- **Desktop (>1024px)**: Full header with all controls and the project selector inline. No overflow menu.
- **Keyboard Accessible**: All controls across tiers expose proper ARIA attributes (aria-expanded, aria-haspopup, aria-label) and support keyboard navigation.
### Plugin Top-Level Views (Graph)
The dashboard now supports plugin-registered top-level views discovered from:
- `GET /api/plugins/dashboard-views`
View identity is persisted as `plugin:${pluginId}:${viewId}` in scoped project storage (`kb:${projectId}:kb-dashboard-task-view`).
Navigation placement in this iteration:
- **Desktop:** Header view overflow menu ("More views")
- **Mobile:** `MobileNavBar` More sheet
`fusion-plugin-dependency-graph` registers `graph` and is host-resolved through an explicit static registry (`app/plugins/pluginViewRegistry.tsx`) for bundle-safe rendering.
### Mobile Bottom Navigation
The dashboard now includes a dedicated bottom tab navigation pattern for mobile viewports (`≤768px`) via `MobileNavBar` (`app/components/MobileNavBar.tsx`). This pattern is designed for narrow screens and Capacitor-wrapped app usage where bottom-tab navigation is the primary interaction model.

View File

@@ -1,7 +1,8 @@
import { useState, useCallback, useEffect, useMemo, useRef, lazy, Suspense } from "react";
import type { Task, TaskDetail } from "@fusion/core";
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
import { Header, useViewportMode } from "./components/Header";
import { Board } from "./components/Board";
import { TaskCard } from "./components/TaskCard";
import { ListView } from "./components/ListView";
import { ProjectOverview } from "./components/ProjectOverview";
import { MissionManager } from "./components/MissionManager";
@@ -44,13 +45,15 @@ import { useMobileKeyboard } from "./hooks/useMobileKeyboard";
import { useSetupReadiness } from "./hooks/useSetupReadiness";
import { useUpdateCheck } from "./hooks/useUpdateCheck";
import { useViewState, type TaskView } from "./hooks/useViewState";
import { usePluginDashboardViews } from "./hooks/usePluginDashboardViews";
import { PluginDashboardViewHost } from "./plugins/PluginDashboardViewHost";
import { useProjectActions } from "./hooks/useProjectActions";
import { useTaskHandlers } from "./hooks/useTaskHandlers";
import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
import { NodeProvider, useNodeContext } from "./context/NodeContext";
import type { AiSessionSummary } from "./api";
import { fetchUnreadCount, reportDashboardPerf, fetchTaskDetail } from "./api";
import { fetchUnreadCount, reportDashboardPerf, fetchTaskDetail, fetchWorkflowSteps } from "./api";
import { getScopedItem, setScopedItem } from "./utils/projectStorage";
import { subscribeSse } from "./sse-bus";
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth";
@@ -202,6 +205,8 @@ function AppInner() {
setThemeMode,
});
const { views: pluginDashboardViews } = usePluginDashboardViews(currentProject?.id);
const handleTaskViewChange = useCallback((newView: TaskView) => {
if (newView === "missions") {
setMissionResumeSessionId(undefined);
@@ -502,6 +507,33 @@ function AppInner() {
}
}, [modalManager, currentProject?.id, addToast]);
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
useEffect(() => {
let cancelled = false;
fetchWorkflowSteps(currentProject?.id)
.then((steps) => {
if (!cancelled) {
setWorkflowSteps(steps);
}
})
.catch(() => {
if (!cancelled) {
setWorkflowSteps([]);
}
});
return () => {
cancelled = true;
};
}, [currentProject?.id]);
const workflowStepNameLookup = useMemo(
() => new Map(workflowSteps.map((step) => [step.id, step.name] as const)),
[workflowSteps],
);
const handleOpenNodes = useCallback(() => {
if (!nodesEnabled) return;
setNodesOpen((prev) => !prev);
@@ -608,6 +640,31 @@ function AppInner() {
}
// Project view
if (taskView.startsWith("plugin:")) {
return (
<PageErrorBoundary>
<PluginDashboardViewHost
taskView={taskView as `plugin:${string}:${string}`}
context={{
projectId: currentProject?.id,
tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks,
workflowSteps,
openTaskDetail: (task, initialTab) => modalManager.openDetailTask(task, initialTab),
renderTaskCard: (task) => (
<TaskCard
task={task}
projectId={currentProject?.id}
onOpenDetail={(value) => modalManager.openDetailTask(value)}
addToast={addToast}
workflowStepNameLookup={workflowStepNameLookup}
/>
),
}}
/>
</PageErrorBoundary>
);
}
if (taskView === "skills") {
if (!settingsLoaded || !skillsEnabled) {
return null;
@@ -940,6 +997,7 @@ function AppInner() {
todoView: todosEnabled,
researchView: researchEnabled,
}}
pluginDashboardViews={pluginDashboardViews}
/>
{viewMode === "project" && currentProject && !nodesOpen && taskView !== "missions" && !modalManager.isPlanningOpen && !sessionBannersHidden && (
<SessionNotificationBanner
@@ -1032,8 +1090,9 @@ function AppInner() {
todoView: todosEnabled,
researchView: researchEnabled,
}}
pluginDashboardViews={pluginDashboardViews}
/>
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && (
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && !taskView.startsWith("plugin:") && (
<QuickChatFAB
projectId={currentProject.id}
addToast={addToast}

View File

@@ -19,6 +19,7 @@ import type {
WorkflowStepResult,
PluginInstallation,
PluginUiSlotDefinition,
PluginDashboardViewDefinition,
TaskDocument,
TaskDocumentRevision,
TaskDocumentWithTask,
@@ -7478,6 +7479,12 @@ export interface PluginUiSlotEntry {
slot: PluginUiSlotDefinition;
}
/** A dashboard view entry returned by GET /api/plugins/dashboard-views */
export interface PluginDashboardViewEntry {
pluginId: string;
view: PluginDashboardViewDefinition;
}
/** Plugin runtime metadata returned by GET /api/plugins/runtimes */
export interface PluginRuntimeInfo {
pluginId: string;
@@ -7492,6 +7499,12 @@ export async function fetchPluginUiSlots(projectId?: string): Promise<PluginUiSl
return api<PluginUiSlotEntry[]>(withProjectId("/plugins/ui-slots", projectId));
}
/** Fetch all top-level dashboard view definitions from active plugins */
export async function fetchPluginDashboardViews(projectId?: string): Promise<PluginDashboardViewEntry[]> {
return api<PluginDashboardViewEntry[]>(withProjectId("/plugins/dashboard-views", projectId));
}
/** Fetch all plugin runtime metadata from active plugins */
export async function fetchPluginRuntimes(projectId?: string): Promise<PluginRuntimeInfo[]> {
return api<PluginRuntimeInfo[]>(withProjectId("/plugins/runtimes", projectId));

View File

@@ -11,6 +11,9 @@ import { NodeHealthDot } from "./NodeHealthDot";
import { PluginSlot } from "./PluginSlot";
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";
export { useViewportMode };
@@ -193,8 +196,8 @@ export interface HeaderProps {
enginePaused?: boolean;
onToggleGlobalPause?: () => void;
onToggleEnginePause?: () => void;
view?: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
onChangeView?: (view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos") => void;
view?: TaskView;
onChangeView?: (view: TaskView) => void;
/** Whether to show the skills tab in the view toggle */
showSkillsTab?: boolean;
/** When true, shows the Agents view tab button. Hidden by default (experimental feature). */
@@ -220,6 +223,7 @@ export interface HeaderProps {
isRemote?: boolean;
/** Experimental feature flags controlling visibility of nav items. */
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; todoView?: boolean; researchView?: boolean };
pluginDashboardViews?: PluginDashboardViewEntry[];
}
export function Header({
@@ -265,6 +269,7 @@ export function Header({
onSelectNode,
isRemote = false,
experimentalFeatures,
pluginDashboardViews = [],
}: HeaderProps) {
const mode: ViewportMode = useViewportMode();
const isMobile = mode === "mobile";
@@ -335,9 +340,10 @@ export function Header({
showSkillsTab ||
experimentalFeatures?.memoryView ||
experimentalFeatures?.devServerView ||
!hideFullNav
!hideFullNav ||
pluginDashboardViews.some((entry) => entry.view.placement !== "primary")
);
}, [experimentalFeatures, showSkillsTab, hideFullNav]);
}, [experimentalFeatures, showSkillsTab, hideFullNav, pluginDashboardViews]);
const getEffectiveViewport = useCallback(() => {
const vv = window.visualViewport;
@@ -1111,7 +1117,7 @@ export function Header({
<>
<button
ref={viewOverflowTriggerRef}
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (experimentalFeatures?.todoView && view === "todos") ? " active" : ""}`}
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (experimentalFeatures?.todoView && view === "todos") || view.startsWith("plugin:") ? " active" : ""}`}
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
title="More views"
aria-label="More views"
@@ -1227,6 +1233,27 @@ export function Header({
<span>Todos</span>
</button>
)}
{pluginDashboardViews
.filter((entry) => entry.view.placement !== "primary")
.sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER))
.map((entry) => {
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
return (
<button
key={`${entry.pluginId}:${entry.view.viewId}`}
className={`view-toggle-overflow-item${view === pluginTaskView ? " active" : ""}`}
onClick={() => {
onChangeView(pluginTaskView);
setIsViewOverflowOpen(false);
}}
role="menuitem"
data-testid={`view-overflow-plugin-${entry.pluginId}-${entry.view.viewId}`}
>
<Grid3X3 size={14} />
<span>{entry.view.label}</span>
</button>
);
})}
</div>
)}
</>

View File

@@ -30,13 +30,16 @@ import {
Zap,
} from "lucide-react";
import { fetchScripts } from "../api";
import type { PluginDashboardViewEntry } from "../api";
import { useViewportMode } from "./Header";
import type { TaskView } from "../hooks/useViewState";
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
export interface MobileNavBarProps {
/** Current task view mode */
view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
view: TaskView;
/** Change task view handler */
onChangeView: (view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos") => void;
onChangeView: (view: TaskView) => void;
/** Whether the ExecutorStatusBar footer is visible */
footerVisible: boolean;
/** Whether any full-screen modal is currently open (hides the tab bar) */
@@ -67,6 +70,7 @@ export interface MobileNavBarProps {
showSkillsTab?: boolean;
/** Experimental feature flags controlling visibility of nav items. */
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; todoView?: boolean; researchView?: boolean };
pluginDashboardViews?: PluginDashboardViewEntry[];
}
function GitHubLogo({ size = 20 }: { size?: number }) {
@@ -114,6 +118,7 @@ export function MobileNavBar({
onViewAllProjects,
showSkillsTab,
experimentalFeatures,
pluginDashboardViews = [],
}: MobileNavBarProps) {
const mode = useViewportMode();
const [isMoreOpen, setIsMoreOpen] = useState(false);
@@ -197,7 +202,8 @@ export function MobileNavBar({
|| view === "dev-server"
|| (view === "todos" && todoViewEnabled)
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|| (view === "skills" && !showSkillsTopLevel);
|| (view === "skills" && !showSkillsTopLevel)
|| view.startsWith("plugin:");
return (
<>
@@ -626,6 +632,25 @@ export function MobileNavBar({
</button>
)}
{pluginDashboardViews
.filter((entry) => entry.view.placement !== "primary")
.sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER))
.map((entry) => {
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
return (
<button
key={`${entry.pluginId}:${entry.view.viewId}`}
type="button"
className="mobile-more-item"
data-testid={`mobile-more-item-plugin-${entry.pluginId}-${entry.view.viewId}`}
onClick={() => handleMoreAction(() => onChangeView(pluginTaskView))}
>
<Grid3X3 />
<span>{entry.view.label}</span>
</button>
);
})}
<div className="mobile-more-separator" />
<button

View File

@@ -50,6 +50,7 @@ vi.mock("../../api", async (importOriginal) => {
fetchAgents: vi.fn(() => Promise.resolve([])),
fetchTaskDetail: vi.fn((id: string) => Promise.resolve({ id, title: `Task ${id}` })),
fetchUnreadCount: vi.fn(() => Promise.resolve({ unreadCount: 0 })),
fetchPluginDashboardViews: vi.fn(() => Promise.resolve([])),
fetchExecutorStats: vi.fn(() => Promise.resolve({
globalPause: false,
enginePaused: false,
@@ -439,7 +440,7 @@ vi.mock("../../hooks/useNodes", () => ({
import { App } from "../../App";
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth";
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels } from "../../api";
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews } from "../../api";
import * as apiNodeModule from "../../hooks/useRemoteNodeData";
async function waitForAppShell(): Promise<void> {
@@ -1480,6 +1481,27 @@ describe("App view switching", () => {
localStorage.removeItem("kb-dashboard-view-mode");
});
it("renders plugin-hosted dashboard view from persisted task view id", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
localStorage.setItem(taskViewStorageKey(), "plugin:fusion-plugin-dependency-graph:graph");
(fetchPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{
pluginId: "fusion-plugin-dependency-graph",
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView", placement: "more" },
},
]);
render(<App />);
await waitFor(() => {
expect(screen.getByText("Zoom In")).toBeInTheDocument();
expect(screen.getByText("Zoom Out")).toBeInTheDocument();
});
localStorage.removeItem(taskViewStorageKey());
localStorage.removeItem("kb-dashboard-view-mode");
});
it("opens planning mode when TodoView triggers planning from todo item", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
localStorage.setItem(taskViewStorageKey(), "todos");

View File

@@ -157,6 +157,25 @@ describe("Header", () => {
expect(screen.getByTestId("view-overflow-todos")).toBeInTheDocument();
});
it("renders plugin dashboard views in desktop view overflow only", () => {
const onChangeView = vi.fn();
renderHeader({
onChangeView,
pluginDashboardViews: [
{
pluginId: "fusion-plugin-dependency-graph",
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView" },
},
],
});
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
const graphItem = screen.getByTestId("view-overflow-plugin-fusion-plugin-dependency-graph-graph");
expect(graphItem).toBeInTheDocument();
fireEvent.click(graphItem);
expect(onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:graph");
});
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

@@ -104,6 +104,26 @@ describe("MobileNavBar", () => {
expect(screen.queryByTestId("mobile-nav-tab-skills")).toBeNull();
});
it("renders plugin dashboard views in More sheet and not top-level tabs", () => {
const props = createDefaultProps();
render(
<MobileNavBar
{...props}
pluginDashboardViews={[
{
pluginId: "fusion-plugin-dependency-graph",
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView" },
},
]}
/>,
);
expect(screen.queryByTestId("mobile-nav-tab-graph")).toBeNull();
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
fireEvent.click(screen.getByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-graph"));
expect(props.onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:graph");
});
it("active tab is highlighted for mailbox", () => {
render(<MobileNavBar {...createDefaultProps()} view="mailbox" />);
expect(screen.getByTestId("mobile-nav-tab-mailbox").className).toContain("mobile-nav-tab--active");

View File

@@ -0,0 +1,42 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { usePluginDashboardViews, __test_clearDashboardViewsCache } from "../usePluginDashboardViews";
import * as api from "../../api";
vi.mock("../../api", () => ({
fetchPluginDashboardViews: vi.fn(),
}));
const mockFetch = vi.mocked(api.fetchPluginDashboardViews);
describe("usePluginDashboardViews", () => {
beforeEach(() => {
__test_clearDashboardViewsCache();
mockFetch.mockReset();
});
it("fetches and returns dashboard views", async () => {
mockFetch.mockResolvedValueOnce([
{ pluginId: "dep", view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" } },
]);
const { result } = renderHook(() => usePluginDashboardViews());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.views).toHaveLength(1);
});
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"));
await waitFor(() => expect(first.result.current.loading).toBe(false));
mockFetch.mockClear();
renderHook(() => usePluginDashboardViews("project-a"));
expect(mockFetch).not.toHaveBeenCalled();
mockFetch.mockResolvedValueOnce([{ pluginId: "b", view: { viewId: "y", label: "Y", componentPath: "./y.js" } }]);
const second = renderHook(() => usePluginDashboardViews("project-b"));
await waitFor(() => expect(second.result.current.loading).toBe(false));
expect(mockFetch).toHaveBeenCalledWith("project-b");
});
});

View File

@@ -275,6 +275,28 @@ describe("useViewState", () => {
});
});
it("restores and persists plugin task views using the canonical composite key", async () => {
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "plugin:fusion-plugin-dependency-graph:graph");
const { result } = renderHook(() =>
useViewState(
createOptions({
currentProject: PROJECT,
}),
),
);
await waitFor(() => {
expect(result.current.taskView).toBe("plugin:fusion-plugin-dependency-graph:graph");
});
await act(async () => {
result.current.setTaskView("plugin:fusion-plugin-dependency-graph:graph");
});
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("plugin:fusion-plugin-dependency-graph:graph");
});
it("restores legacy views (board/list/agents/missions/chat) from scoped storage", async () => {
const legacyViews = ["board", "list", "agents", "missions", "chat"] as const;

View File

@@ -0,0 +1,63 @@
import { 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;
export function __test_clearDashboardViewsCache(): void {
dashboardViewsCache.clear();
}
export function usePluginDashboardViews(projectId?: string): {
views: PluginDashboardViewEntry[];
loading: boolean;
error: string | null;
} {
const [views, setViews] = useState<PluginDashboardViewEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const initialLoadCompleteRef = useRef(false);
useEffect(() => {
const cacheKey = projectId ?? "default";
let cancelled = false;
async function load(): Promise<void> {
const cached = dashboardViewsCache.get(cacheKey);
if (cached && Date.now() < cached.expiresAt) {
if (cancelled) return;
setViews(cached.views);
setLoading(false);
return;
}
if (!initialLoadCompleteRef.current) {
setLoading(true);
}
setError(null);
try {
const data = await fetchPluginDashboardViews(projectId);
if (cancelled) return;
dashboardViewsCache.set(cacheKey, { views: data, expiresAt: Date.now() + CACHE_TTL_MS });
setViews(data);
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : "Failed to fetch plugin dashboard views");
} finally {
if (!cancelled) {
setLoading(false);
initialLoadCompleteRef.current = true;
}
}
}
void load();
return () => {
cancelled = true;
};
}, [projectId]);
return useMemo(() => ({ views, loading, error }), [views, loading, error]);
}

View File

@@ -4,9 +4,11 @@ import type { ProjectInfo } from "../api";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
export type ViewMode = "overview" | "project";
export type TaskView = "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
export type BuiltInTaskView = "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
export type PluginTaskView = `plugin:${string}:${string}`;
export type TaskView = BuiltInTaskView | PluginTaskView;
const TASK_VIEWS: readonly TaskView[] = [
const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
"board",
"list",
"agents",
@@ -24,8 +26,16 @@ const TASK_VIEWS: readonly TaskView[] = [
"todos",
];
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 value !== null && TASK_VIEWS.includes(value as TaskView);
return isBuiltInTaskView(value) || isPluginTaskView(value);
}
function normalizeTaskView(value: TaskView): TaskView {

View File

@@ -0,0 +1,20 @@
import { resolvePluginDashboardView, MissingPluginDashboardView, parsePluginTaskViewId } from "./pluginViewRegistry";
import type { PluginDashboardHostContext, 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} />;
}

View File

@@ -0,0 +1,27 @@
.plugin-dashboard-view-missing {
margin: var(--space-lg);
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.plugin-dashboard-view-missing-title {
margin: 0;
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 1rem;
}
.plugin-dashboard-view-missing-description {
margin: 0;
color: var(--text-muted);
}
@media (max-width: 768px) {
.plugin-dashboard-view-missing {
margin: var(--space-md);
padding: var(--space-md);
}
}

View File

@@ -0,0 +1,64 @@
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 "./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;
}
export interface PluginDashboardViewComponentProps {
context: PluginDashboardHostContext;
}
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 {
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 };
}
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;
}
export function MissingPluginDashboardView({ pluginId, viewId }: { pluginId: string; viewId: string }): ReactNode {
return (
<section className="card plugin-dashboard-view-missing">
<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.
</p>
</section>
);
}

View File

@@ -49,6 +49,7 @@
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.36.4",
"@fusion-plugin-examples/dependency-graph": "workspace:*",
"@fusion-plugin-examples/hermes-runtime": "workspace:*",
"@fusion-plugin-examples/openclaw-runtime": "workspace:*",
"@fusion-plugin-examples/paperclip-runtime": "workspace:*",

View File

@@ -92,6 +92,7 @@ function createMockPluginLoader(overrides: Partial<PluginLoader> = {}): PluginLo
getPluginTools: vi.fn().mockReturnValue([]),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPluginUiSlots: vi.fn().mockReturnValue([]),
getPluginDashboardViews: vi.fn().mockReturnValue([]),
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
stopAllPlugins: vi.fn().mockResolvedValue(undefined),
invokeHook: vi.fn().mockResolvedValue(undefined),
@@ -678,6 +679,58 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", ()
});
// ══════════════════════════════════════════════════════════════════
describe("GET /api/plugins/dashboard-views", () => {
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
let store: TaskStore;
beforeEach(() => {
vi.clearAllMocks();
pluginStore = createMockPluginStore();
pluginLoader = createMockPluginLoader();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { pluginStore, pluginLoader }));
return app;
}
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");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("returns aggregated dashboard views with pluginId and view", async () => {
const mockViews = [
{
pluginId: "dep-graph",
view: {
viewId: "graph",
label: "Graph",
componentPath: "./views/Graph.js",
icon: "Network",
placement: "more",
},
},
];
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockReturnValue(mockViews);
const res = await performGet(buildApp(), "/api/plugins/dashboard-views");
expect(res.status).toBe(200);
expect(res.body).toEqual(mockViews);
});
});
describe("GET /api/plugins/ui-slots", () => {
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;

View File

@@ -3031,6 +3031,17 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
res.json(slots);
});
/**
* GET /api/plugins/dashboard-views
* 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 = options?.pluginLoader?.getPluginDashboardViews() ?? [];
res.json(views);
});
/**
* GET /api/plugins/runtimes
* Get all plugin runtime metadata from active plugins.

View File

@@ -240,6 +240,22 @@ describe("Plugin SDK", () => {
});
});
it("PluginDashboardViewDefinition can be used in FusionPlugin", () => {
const plugin: FusionPlugin = {
manifest: { id: "test", name: "Test", version: "1.0.0" },
state: "installed",
hooks: {},
tools: [],
routes: [],
dashboardViews: [
{ viewId: "graph", label: "Graph", componentPath: "./views/Graph.js", placement: "more" },
],
};
expect(plugin.dashboardViews).toHaveLength(1);
expect(plugin.dashboardViews?.[0].viewId).toBe("graph");
});
// ── validatePluginManifest ───────────────────────────────────────────
describe("validatePluginManifest", () => {

View File

@@ -49,6 +49,7 @@ export type {
PluginRouteDefinition,
PluginRouteMethod,
PluginUiSlotDefinition,
PluginDashboardViewDefinition,
PluginRuntimeManifestMetadata,
PluginRuntimeFactory,
PluginRuntimeRegistration,