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:
5
.changeset/fn-3079-plugin-dashboard-views.md
Normal file
5
.changeset/fn-3079-plugin-dashboard-views.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add host support for plugin-registered top-level dashboard views and ship the first plugin-first Graph view surface for dependency visualization.
|
||||||
@@ -486,7 +486,39 @@ export default function CiBadge() {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Registering Agent Runtimes
|
## 8. Registering Top-Level Dashboard Views
|
||||||
|
|
||||||
|
Top-level views are a **sibling contribution type** to `uiSlots`.
|
||||||
|
|
||||||
|
- `uiSlots` are embedded surfaces (task detail tab, header action, etc.)
|
||||||
|
- `dashboardViews` are full-screen destinations in dashboard navigation
|
||||||
|
|
||||||
|
Register `dashboardViews` on the plugin definition:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { PluginDashboardViewDefinition } from "@fusion/plugin-sdk";
|
||||||
|
|
||||||
|
const dashboardViews: PluginDashboardViewDefinition[] = [
|
||||||
|
{
|
||||||
|
viewId: "graph",
|
||||||
|
label: "Graph",
|
||||||
|
componentPath: "./src/DependencyGraphView.tsx",
|
||||||
|
icon: "Network",
|
||||||
|
order: 40,
|
||||||
|
placement: "more",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
Current host constraints:
|
||||||
|
- Discovery API: `GET /api/plugins/dashboard-views`
|
||||||
|
- The dashboard **does not eval or filesystem-load plugin code in-browser**
|
||||||
|
- `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}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Registering Agent Runtimes
|
||||||
|
|
||||||
Plugins can provide custom agent runtime implementations that extend the Fusion engine's ability to execute agent sessions. Runtimes are discovered through the plugin discovery pipeline and can be used by the engine to route agent session creation.
|
Plugins can provide custom agent runtime implementations that extend the Fusion engine's ability to execute agent sessions. Runtimes are discovered through the plugin discovery pipeline and can be used by the engine to route agent session creation.
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,10 @@ Concrete references:
|
|||||||
|
|
||||||
- `PluginStore` (`plugin-store.ts`) stores plugin installation state and settings (`plugins` table)
|
- `PluginStore` (`plugin-store.ts`) stores plugin installation state and settings (`plugins` table)
|
||||||
- `PluginLoader` (`plugin-loader.ts`) loads/unloads plugin modules and emits lifecycle events
|
- `PluginLoader` (`plugin-loader.ts`) loads/unloads plugin modules and emits lifecycle events
|
||||||
|
- Plugin contributions now include both embedded `uiSlots` and top-level `dashboardViews`
|
||||||
|
- Discovery endpoints:
|
||||||
|
- `GET /api/plugins/ui-slots`
|
||||||
|
- `GET /api/plugins/dashboard-views`
|
||||||
- Dashboard management routes are implemented in `packages/dashboard/src/plugin-routes.ts`
|
- Dashboard management routes are implemented in `packages/dashboard/src/plugin-routes.ts`
|
||||||
|
|
||||||
### Prompt Overrides
|
### Prompt Overrides
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────
|
// ── getPluginRuntimes ─────────────────────────────────────────────
|
||||||
|
|
||||||
describe("getPluginRuntimes", () => {
|
describe("getPluginRuntimes", () => {
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export type {
|
|||||||
PluginRouteDefinition,
|
PluginRouteDefinition,
|
||||||
PluginRouteMethod,
|
PluginRouteMethod,
|
||||||
PluginUiSlotDefinition,
|
PluginUiSlotDefinition,
|
||||||
|
PluginDashboardViewDefinition,
|
||||||
PluginRuntimeManifestMetadata,
|
PluginRuntimeManifestMetadata,
|
||||||
PluginRuntimeFactory,
|
PluginRuntimeFactory,
|
||||||
PluginRuntimeRegistration,
|
PluginRuntimeRegistration,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type {
|
|||||||
PluginToolDefinition,
|
PluginToolDefinition,
|
||||||
PluginRouteDefinition,
|
PluginRouteDefinition,
|
||||||
PluginUiSlotDefinition,
|
PluginUiSlotDefinition,
|
||||||
|
PluginDashboardViewDefinition,
|
||||||
PluginRuntimeRegistration,
|
PluginRuntimeRegistration,
|
||||||
PluginInstallation,
|
PluginInstallation,
|
||||||
PluginSkillContribution,
|
PluginSkillContribution,
|
||||||
@@ -775,6 +776,22 @@ export class PluginLoader extends EventEmitter<{
|
|||||||
return slots;
|
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.
|
* Get all runtime registrations from loaded plugins.
|
||||||
* Returns plugin ownership metadata along with the runtime registration.
|
* Returns plugin ownership metadata along with the runtime registration.
|
||||||
|
|||||||
@@ -179,6 +179,28 @@ export interface PluginUiSlotDefinition {
|
|||||||
componentPath: string;
|
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 ─────────────────────────────────────────────────
|
// ── Plugin Runtimes ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -356,6 +378,8 @@ export interface FusionPlugin {
|
|||||||
tools?: PluginToolDefinition[];
|
tools?: PluginToolDefinition[];
|
||||||
routes?: PluginRouteDefinition[];
|
routes?: PluginRouteDefinition[];
|
||||||
uiSlots?: PluginUiSlotDefinition[];
|
uiSlots?: PluginUiSlotDefinition[];
|
||||||
|
/** Plugin-contributed top-level dashboard views. */
|
||||||
|
dashboardViews?: PluginDashboardViewDefinition[];
|
||||||
/** Agent runtime registration for providing custom runtime implementations */
|
/** Agent runtime registration for providing custom runtime implementations */
|
||||||
runtime?: PluginRuntimeRegistration;
|
runtime?: PluginRuntimeRegistration;
|
||||||
/** Plugin-contributed skills surfaced by the skill resolver. */
|
/** Plugin-contributed skills surfaced by the skill resolver. */
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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
|
### 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useCallback, useEffect, useMemo, useRef, lazy, Suspense } from "react";
|
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 { Header, useViewportMode } from "./components/Header";
|
||||||
import { Board } from "./components/Board";
|
import { Board } from "./components/Board";
|
||||||
|
import { TaskCard } from "./components/TaskCard";
|
||||||
import { ListView } from "./components/ListView";
|
import { ListView } from "./components/ListView";
|
||||||
import { ProjectOverview } from "./components/ProjectOverview";
|
import { ProjectOverview } from "./components/ProjectOverview";
|
||||||
import { MissionManager } from "./components/MissionManager";
|
import { MissionManager } from "./components/MissionManager";
|
||||||
@@ -44,13 +45,15 @@ import { useMobileKeyboard } from "./hooks/useMobileKeyboard";
|
|||||||
import { useSetupReadiness } from "./hooks/useSetupReadiness";
|
import { useSetupReadiness } from "./hooks/useSetupReadiness";
|
||||||
import { useUpdateCheck } from "./hooks/useUpdateCheck";
|
import { useUpdateCheck } from "./hooks/useUpdateCheck";
|
||||||
import { useViewState, type TaskView } from "./hooks/useViewState";
|
import { useViewState, type TaskView } from "./hooks/useViewState";
|
||||||
|
import { usePluginDashboardViews } from "./hooks/usePluginDashboardViews";
|
||||||
|
import { PluginDashboardViewHost } from "./plugins/PluginDashboardViewHost";
|
||||||
import { useProjectActions } from "./hooks/useProjectActions";
|
import { useProjectActions } from "./hooks/useProjectActions";
|
||||||
import { useTaskHandlers } from "./hooks/useTaskHandlers";
|
import { useTaskHandlers } from "./hooks/useTaskHandlers";
|
||||||
import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
|
import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
|
||||||
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
|
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
|
||||||
import { NodeProvider, useNodeContext } from "./context/NodeContext";
|
import { NodeProvider, useNodeContext } from "./context/NodeContext";
|
||||||
import type { AiSessionSummary } from "./api";
|
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 { getScopedItem, setScopedItem } from "./utils/projectStorage";
|
||||||
import { subscribeSse } from "./sse-bus";
|
import { subscribeSse } from "./sse-bus";
|
||||||
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth";
|
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth";
|
||||||
@@ -202,6 +205,8 @@ function AppInner() {
|
|||||||
setThemeMode,
|
setThemeMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { views: pluginDashboardViews } = usePluginDashboardViews(currentProject?.id);
|
||||||
|
|
||||||
const handleTaskViewChange = useCallback((newView: TaskView) => {
|
const handleTaskViewChange = useCallback((newView: TaskView) => {
|
||||||
if (newView === "missions") {
|
if (newView === "missions") {
|
||||||
setMissionResumeSessionId(undefined);
|
setMissionResumeSessionId(undefined);
|
||||||
@@ -502,6 +507,33 @@ function AppInner() {
|
|||||||
}
|
}
|
||||||
}, [modalManager, currentProject?.id, addToast]);
|
}, [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(() => {
|
const handleOpenNodes = useCallback(() => {
|
||||||
if (!nodesEnabled) return;
|
if (!nodesEnabled) return;
|
||||||
setNodesOpen((prev) => !prev);
|
setNodesOpen((prev) => !prev);
|
||||||
@@ -608,6 +640,31 @@ function AppInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Project view
|
// 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 (taskView === "skills") {
|
||||||
if (!settingsLoaded || !skillsEnabled) {
|
if (!settingsLoaded || !skillsEnabled) {
|
||||||
return null;
|
return null;
|
||||||
@@ -940,6 +997,7 @@ function AppInner() {
|
|||||||
todoView: todosEnabled,
|
todoView: todosEnabled,
|
||||||
researchView: researchEnabled,
|
researchView: researchEnabled,
|
||||||
}}
|
}}
|
||||||
|
pluginDashboardViews={pluginDashboardViews}
|
||||||
/>
|
/>
|
||||||
{viewMode === "project" && currentProject && !nodesOpen && taskView !== "missions" && !modalManager.isPlanningOpen && !sessionBannersHidden && (
|
{viewMode === "project" && currentProject && !nodesOpen && taskView !== "missions" && !modalManager.isPlanningOpen && !sessionBannersHidden && (
|
||||||
<SessionNotificationBanner
|
<SessionNotificationBanner
|
||||||
@@ -1032,8 +1090,9 @@ function AppInner() {
|
|||||||
todoView: todosEnabled,
|
todoView: todosEnabled,
|
||||||
researchView: researchEnabled,
|
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
|
<QuickChatFAB
|
||||||
projectId={currentProject.id}
|
projectId={currentProject.id}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
WorkflowStepResult,
|
WorkflowStepResult,
|
||||||
PluginInstallation,
|
PluginInstallation,
|
||||||
PluginUiSlotDefinition,
|
PluginUiSlotDefinition,
|
||||||
|
PluginDashboardViewDefinition,
|
||||||
TaskDocument,
|
TaskDocument,
|
||||||
TaskDocumentRevision,
|
TaskDocumentRevision,
|
||||||
TaskDocumentWithTask,
|
TaskDocumentWithTask,
|
||||||
@@ -7478,6 +7479,12 @@ export interface PluginUiSlotEntry {
|
|||||||
slot: PluginUiSlotDefinition;
|
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 */
|
/** Plugin runtime metadata returned by GET /api/plugins/runtimes */
|
||||||
export interface PluginRuntimeInfo {
|
export interface PluginRuntimeInfo {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
@@ -7492,6 +7499,12 @@ export async function fetchPluginUiSlots(projectId?: string): Promise<PluginUiSl
|
|||||||
return api<PluginUiSlotEntry[]>(withProjectId("/plugins/ui-slots", projectId));
|
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 */
|
/** Fetch all plugin runtime metadata from active plugins */
|
||||||
export async function fetchPluginRuntimes(projectId?: string): Promise<PluginRuntimeInfo[]> {
|
export async function fetchPluginRuntimes(projectId?: string): Promise<PluginRuntimeInfo[]> {
|
||||||
return api<PluginRuntimeInfo[]>(withProjectId("/plugins/runtimes", projectId));
|
return api<PluginRuntimeInfo[]>(withProjectId("/plugins/runtimes", projectId));
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { NodeHealthDot } from "./NodeHealthDot";
|
|||||||
import { PluginSlot } from "./PluginSlot";
|
import { PluginSlot } from "./PluginSlot";
|
||||||
import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
|
import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
|
||||||
import { getTrailingPath } from "../utils/pathDisplay";
|
import { getTrailingPath } from "../utils/pathDisplay";
|
||||||
|
import type { TaskView } from "../hooks/useViewState";
|
||||||
|
import type { PluginDashboardViewEntry } from "../api";
|
||||||
|
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
|
||||||
|
|
||||||
export { useViewportMode };
|
export { useViewportMode };
|
||||||
|
|
||||||
@@ -193,8 +196,8 @@ export interface HeaderProps {
|
|||||||
enginePaused?: boolean;
|
enginePaused?: boolean;
|
||||||
onToggleGlobalPause?: () => void;
|
onToggleGlobalPause?: () => void;
|
||||||
onToggleEnginePause?: () => void;
|
onToggleEnginePause?: () => void;
|
||||||
view?: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
|
view?: TaskView;
|
||||||
onChangeView?: (view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos") => void;
|
onChangeView?: (view: TaskView) => void;
|
||||||
/** Whether to show the skills tab in the view toggle */
|
/** Whether to show the skills tab in the view toggle */
|
||||||
showSkillsTab?: boolean;
|
showSkillsTab?: boolean;
|
||||||
/** When true, shows the Agents view tab button. Hidden by default (experimental feature). */
|
/** When true, shows the Agents view tab button. Hidden by default (experimental feature). */
|
||||||
@@ -220,6 +223,7 @@ export interface HeaderProps {
|
|||||||
isRemote?: boolean;
|
isRemote?: boolean;
|
||||||
/** Experimental feature flags controlling visibility of nav items. */
|
/** Experimental feature flags controlling visibility of nav items. */
|
||||||
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; todoView?: boolean; researchView?: boolean };
|
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; todoView?: boolean; researchView?: boolean };
|
||||||
|
pluginDashboardViews?: PluginDashboardViewEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({
|
export function Header({
|
||||||
@@ -265,6 +269,7 @@ export function Header({
|
|||||||
onSelectNode,
|
onSelectNode,
|
||||||
isRemote = false,
|
isRemote = false,
|
||||||
experimentalFeatures,
|
experimentalFeatures,
|
||||||
|
pluginDashboardViews = [],
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
const mode: ViewportMode = useViewportMode();
|
const mode: ViewportMode = useViewportMode();
|
||||||
const isMobile = mode === "mobile";
|
const isMobile = mode === "mobile";
|
||||||
@@ -335,9 +340,10 @@ export function Header({
|
|||||||
showSkillsTab ||
|
showSkillsTab ||
|
||||||
experimentalFeatures?.memoryView ||
|
experimentalFeatures?.memoryView ||
|
||||||
experimentalFeatures?.devServerView ||
|
experimentalFeatures?.devServerView ||
|
||||||
!hideFullNav
|
!hideFullNav ||
|
||||||
|
pluginDashboardViews.some((entry) => entry.view.placement !== "primary")
|
||||||
);
|
);
|
||||||
}, [experimentalFeatures, showSkillsTab, hideFullNav]);
|
}, [experimentalFeatures, showSkillsTab, hideFullNav, pluginDashboardViews]);
|
||||||
|
|
||||||
const getEffectiveViewport = useCallback(() => {
|
const getEffectiveViewport = useCallback(() => {
|
||||||
const vv = window.visualViewport;
|
const vv = window.visualViewport;
|
||||||
@@ -1111,7 +1117,7 @@ export function Header({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
ref={viewOverflowTriggerRef}
|
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)}
|
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
|
||||||
title="More views"
|
title="More views"
|
||||||
aria-label="More views"
|
aria-label="More views"
|
||||||
@@ -1227,6 +1233,27 @@ export function Header({
|
|||||||
<span>Todos</span>
|
<span>Todos</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -30,13 +30,16 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { fetchScripts } from "../api";
|
import { fetchScripts } from "../api";
|
||||||
|
import type { PluginDashboardViewEntry } from "../api";
|
||||||
import { useViewportMode } from "./Header";
|
import { useViewportMode } from "./Header";
|
||||||
|
import type { TaskView } from "../hooks/useViewState";
|
||||||
|
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
|
||||||
|
|
||||||
export interface MobileNavBarProps {
|
export interface MobileNavBarProps {
|
||||||
/** Current task view mode */
|
/** 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 */
|
/** 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 */
|
/** Whether the ExecutorStatusBar footer is visible */
|
||||||
footerVisible: boolean;
|
footerVisible: boolean;
|
||||||
/** Whether any full-screen modal is currently open (hides the tab bar) */
|
/** Whether any full-screen modal is currently open (hides the tab bar) */
|
||||||
@@ -67,6 +70,7 @@ export interface MobileNavBarProps {
|
|||||||
showSkillsTab?: boolean;
|
showSkillsTab?: boolean;
|
||||||
/** Experimental feature flags controlling visibility of nav items. */
|
/** Experimental feature flags controlling visibility of nav items. */
|
||||||
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; todoView?: boolean; researchView?: boolean };
|
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; todoView?: boolean; researchView?: boolean };
|
||||||
|
pluginDashboardViews?: PluginDashboardViewEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function GitHubLogo({ size = 20 }: { size?: number }) {
|
function GitHubLogo({ size = 20 }: { size?: number }) {
|
||||||
@@ -114,6 +118,7 @@ export function MobileNavBar({
|
|||||||
onViewAllProjects,
|
onViewAllProjects,
|
||||||
showSkillsTab,
|
showSkillsTab,
|
||||||
experimentalFeatures,
|
experimentalFeatures,
|
||||||
|
pluginDashboardViews = [],
|
||||||
}: MobileNavBarProps) {
|
}: MobileNavBarProps) {
|
||||||
const mode = useViewportMode();
|
const mode = useViewportMode();
|
||||||
const [isMoreOpen, setIsMoreOpen] = useState(false);
|
const [isMoreOpen, setIsMoreOpen] = useState(false);
|
||||||
@@ -197,7 +202,8 @@ export function MobileNavBar({
|
|||||||
|| view === "dev-server"
|
|| view === "dev-server"
|
||||||
|| (view === "todos" && todoViewEnabled)
|
|| (view === "todos" && todoViewEnabled)
|
||||||
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|
||||||
|| (view === "skills" && !showSkillsTopLevel);
|
|| (view === "skills" && !showSkillsTopLevel)
|
||||||
|
|| view.startsWith("plugin:");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -626,6 +632,25 @@ export function MobileNavBar({
|
|||||||
</button>
|
</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" />
|
<div className="mobile-more-separator" />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ vi.mock("../../api", async (importOriginal) => {
|
|||||||
fetchAgents: vi.fn(() => Promise.resolve([])),
|
fetchAgents: vi.fn(() => Promise.resolve([])),
|
||||||
fetchTaskDetail: vi.fn((id: string) => Promise.resolve({ id, title: `Task ${id}` })),
|
fetchTaskDetail: vi.fn((id: string) => Promise.resolve({ id, title: `Task ${id}` })),
|
||||||
fetchUnreadCount: vi.fn(() => Promise.resolve({ unreadCount: 0 })),
|
fetchUnreadCount: vi.fn(() => Promise.resolve({ unreadCount: 0 })),
|
||||||
|
fetchPluginDashboardViews: vi.fn(() => Promise.resolve([])),
|
||||||
fetchExecutorStats: vi.fn(() => Promise.resolve({
|
fetchExecutorStats: vi.fn(() => Promise.resolve({
|
||||||
globalPause: false,
|
globalPause: false,
|
||||||
enginePaused: false,
|
enginePaused: false,
|
||||||
@@ -439,7 +440,7 @@ vi.mock("../../hooks/useNodes", () => ({
|
|||||||
|
|
||||||
import { App } from "../../App";
|
import { App } from "../../App";
|
||||||
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth";
|
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";
|
import * as apiNodeModule from "../../hooks/useRemoteNodeData";
|
||||||
|
|
||||||
async function waitForAppShell(): Promise<void> {
|
async function waitForAppShell(): Promise<void> {
|
||||||
@@ -1480,6 +1481,27 @@ describe("App view switching", () => {
|
|||||||
localStorage.removeItem("kb-dashboard-view-mode");
|
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 () => {
|
it("opens planning mode when TodoView triggers planning from todo item", async () => {
|
||||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||||
localStorage.setItem(taskViewStorageKey(), "todos");
|
localStorage.setItem(taskViewStorageKey(), "todos");
|
||||||
|
|||||||
@@ -157,6 +157,25 @@ describe("Header", () => {
|
|||||||
expect(screen.getByTestId("view-overflow-todos")).toBeInTheDocument();
|
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", () => {
|
it("renders view overflow trigger when an experimental overflow feature is enabled", () => {
|
||||||
renderHeader({ onChangeView: noop, experimentalFeatures: { insights: true } });
|
renderHeader({ onChangeView: noop, experimentalFeatures: { insights: true } });
|
||||||
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
|
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
|
||||||
|
|||||||
@@ -104,6 +104,26 @@ describe("MobileNavBar", () => {
|
|||||||
expect(screen.queryByTestId("mobile-nav-tab-skills")).toBeNull();
|
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", () => {
|
it("active tab is highlighted for mailbox", () => {
|
||||||
render(<MobileNavBar {...createDefaultProps()} view="mailbox" />);
|
render(<MobileNavBar {...createDefaultProps()} view="mailbox" />);
|
||||||
expect(screen.getByTestId("mobile-nav-tab-mailbox").className).toContain("mobile-nav-tab--active");
|
expect(screen.getByTestId("mobile-nav-tab-mailbox").className).toContain("mobile-nav-tab--active");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 () => {
|
it("restores legacy views (board/list/agents/missions/chat) from scoped storage", async () => {
|
||||||
const legacyViews = ["board", "list", "agents", "missions", "chat"] as const;
|
const legacyViews = ["board", "list", "agents", "missions", "chat"] as const;
|
||||||
|
|
||||||
|
|||||||
63
packages/dashboard/app/hooks/usePluginDashboardViews.ts
Normal file
63
packages/dashboard/app/hooks/usePluginDashboardViews.ts
Normal 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]);
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@ import type { ProjectInfo } from "../api";
|
|||||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||||
|
|
||||||
export type ViewMode = "overview" | "project";
|
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",
|
"board",
|
||||||
"list",
|
"list",
|
||||||
"agents",
|
"agents",
|
||||||
@@ -24,8 +26,16 @@ const TASK_VIEWS: readonly TaskView[] = [
|
|||||||
"todos",
|
"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 {
|
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 {
|
function normalizeTaskView(value: TaskView): TaskView {
|
||||||
|
|||||||
20
packages/dashboard/app/plugins/PluginDashboardViewHost.tsx
Normal file
20
packages/dashboard/app/plugins/PluginDashboardViewHost.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
27
packages/dashboard/app/plugins/pluginViewRegistry.css
Normal file
27
packages/dashboard/app/plugins/pluginViewRegistry.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
packages/dashboard/app/plugins/pluginViewRegistry.tsx
Normal file
64
packages/dashboard/app/plugins/pluginViewRegistry.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@
|
|||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/theme-one-dark": "^6.1.2",
|
"@codemirror/theme-one-dark": "^6.1.2",
|
||||||
"@codemirror/view": "^6.36.4",
|
"@codemirror/view": "^6.36.4",
|
||||||
|
"@fusion-plugin-examples/dependency-graph": "workspace:*",
|
||||||
"@fusion-plugin-examples/hermes-runtime": "workspace:*",
|
"@fusion-plugin-examples/hermes-runtime": "workspace:*",
|
||||||
"@fusion-plugin-examples/openclaw-runtime": "workspace:*",
|
"@fusion-plugin-examples/openclaw-runtime": "workspace:*",
|
||||||
"@fusion-plugin-examples/paperclip-runtime": "workspace:*",
|
"@fusion-plugin-examples/paperclip-runtime": "workspace:*",
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ function createMockPluginLoader(overrides: Partial<PluginLoader> = {}): PluginLo
|
|||||||
getPluginTools: vi.fn().mockReturnValue([]),
|
getPluginTools: vi.fn().mockReturnValue([]),
|
||||||
getPluginRoutes: vi.fn().mockReturnValue([]),
|
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||||
getPluginUiSlots: vi.fn().mockReturnValue([]),
|
getPluginUiSlots: vi.fn().mockReturnValue([]),
|
||||||
|
getPluginDashboardViews: vi.fn().mockReturnValue([]),
|
||||||
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
|
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
|
||||||
stopAllPlugins: vi.fn().mockResolvedValue(undefined),
|
stopAllPlugins: vi.fn().mockResolvedValue(undefined),
|
||||||
invokeHook: 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", () => {
|
describe("GET /api/plugins/ui-slots", () => {
|
||||||
let pluginStore: PluginStore;
|
let pluginStore: PluginStore;
|
||||||
let pluginLoader: PluginLoader;
|
let pluginLoader: PluginLoader;
|
||||||
|
|||||||
@@ -3031,6 +3031,17 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
res.json(slots);
|
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 /api/plugins/runtimes
|
||||||
* Get all plugin runtime metadata from active plugins.
|
* Get all plugin runtime metadata from active plugins.
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────
|
// ── validatePluginManifest ───────────────────────────────────────────
|
||||||
|
|
||||||
describe("validatePluginManifest", () => {
|
describe("validatePluginManifest", () => {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export type {
|
|||||||
PluginRouteDefinition,
|
PluginRouteDefinition,
|
||||||
PluginRouteMethod,
|
PluginRouteMethod,
|
||||||
PluginUiSlotDefinition,
|
PluginUiSlotDefinition,
|
||||||
|
PluginDashboardViewDefinition,
|
||||||
PluginRuntimeManifestMetadata,
|
PluginRuntimeManifestMetadata,
|
||||||
PluginRuntimeFactory,
|
PluginRuntimeFactory,
|
||||||
PluginRuntimeRegistration,
|
PluginRuntimeRegistration,
|
||||||
|
|||||||
11
plugins/fusion-plugin-dependency-graph/README.md
Normal file
11
plugins/fusion-plugin-dependency-graph/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# fusion-plugin-dependency-graph
|
||||||
|
|
||||||
|
Plugin-provided top-level **Graph** dashboard view for Fusion.
|
||||||
|
|
||||||
|
- Registers `dashboardViews: [{ viewId: "graph", placement: "more" }]`
|
||||||
|
- Renders active task dependency graph for `triage`, `todo`, `in-progress`, `in-review`
|
||||||
|
- Excludes `done` and `archived`
|
||||||
|
- Persists drag positions in browser localStorage at:
|
||||||
|
- `kb:${projectId}:dependency-graph-positions`
|
||||||
|
|
||||||
|
The first version uses a lightweight custom SVG/HTML renderer (no React Flow dependency).
|
||||||
16
plugins/fusion-plugin-dependency-graph/manifest.json
Normal file
16
plugins/fusion-plugin-dependency-graph/manifest.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"id": "fusion-plugin-dependency-graph",
|
||||||
|
"name": "Dependency Graph",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Top-level dependency graph dashboard view",
|
||||||
|
"dashboardViews": [
|
||||||
|
{
|
||||||
|
"viewId": "graph",
|
||||||
|
"label": "Graph",
|
||||||
|
"componentPath": "./src/DependencyGraphView.tsx",
|
||||||
|
"icon": "Network",
|
||||||
|
"placement": "more",
|
||||||
|
"order": 40
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
32
plugins/fusion-plugin-dependency-graph/package.json
Normal file
32
plugins/fusion-plugin-dependency-graph/package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@fusion-plugin-examples/dependency-graph",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Dependency graph dashboard view plugin for Fusion",
|
||||||
|
"private": true,
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"import": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"./dashboard-view": {
|
||||||
|
"types": "./src/DependencyGraphView.tsx",
|
||||||
|
"import": "./src/DependencyGraphView.tsx"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "vitest run --silent=passed-only --reporter=dot"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fusion/plugin-sdk": "workspace:*",
|
||||||
|
"@fusion/core": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.5.2",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^3.2.4",
|
||||||
|
"react": "^19.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
.dependency-graph-view {
|
||||||
|
--dependency-graph-canvas-min-height: calc(var(--space-2xl) * 10);
|
||||||
|
--dependency-graph-canvas-min-height-mobile: calc(var(--space-2xl) * 8);
|
||||||
|
--dependency-graph-edge-width: var(--btn-border-width);
|
||||||
|
--dependency-graph-node-max-width-mobile: calc(var(--space-2xl) * 10);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-graph-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-graph-canvas {
|
||||||
|
overflow: auto;
|
||||||
|
border: var(--btn-border-width) solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
min-height: var(--dependency-graph-canvas-min-height);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-graph-canvas:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-graph-scene {
|
||||||
|
position: relative;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-graph-edges {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-graph-edge {
|
||||||
|
stroke: var(--border);
|
||||||
|
stroke-width: var(--dependency-graph-edge-width);
|
||||||
|
transition: stroke var(--transition-fast), opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-graph-edge.is-related {
|
||||||
|
stroke: var(--todo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-graph-edge.is-dimmed {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-graph-node {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
cursor: grab;
|
||||||
|
transition: opacity var(--transition-fast), filter var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-graph-node:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-graph-node .card {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-graph-node.is-selected .card {
|
||||||
|
box-shadow: var(--focus-ring-strong);
|
||||||
|
border-color: var(--todo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-graph-node.is-related:not(.is-selected) .card {
|
||||||
|
border-color: var(--in-progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-graph-node.is-dimmed {
|
||||||
|
opacity: 0.5;
|
||||||
|
filter: saturate(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dependency-graph-view {
|
||||||
|
padding: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-graph-canvas {
|
||||||
|
min-height: var(--dependency-graph-canvas-min-height-mobile);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-graph-node {
|
||||||
|
width: min(100%, var(--dependency-graph-node-max-width-mobile)) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
||||||
|
import type { Task } from "@fusion/core";
|
||||||
|
import { loadPositions, savePositions } from "./storage";
|
||||||
|
import "./DependencyGraphView.css";
|
||||||
|
|
||||||
|
const ACTIVE_COLUMNS = new Set(["triage", "todo", "in-progress", "in-review"]);
|
||||||
|
const NODE_WIDTH_REM = 18;
|
||||||
|
const NODE_HEIGHT_REM = 9;
|
||||||
|
const GRID_GAP_X_REM = 3;
|
||||||
|
const GRID_GAP_Y_REM = 4;
|
||||||
|
const DRAG_THRESHOLD_REM = 0.5;
|
||||||
|
const SCENE_PADDING_REM = 2;
|
||||||
|
const FIT_PADDING_REM = 2;
|
||||||
|
const MIN_SCALE = 0.4;
|
||||||
|
const MAX_SCALE = 2;
|
||||||
|
|
||||||
|
export interface DependencyGraphHostContext {
|
||||||
|
projectId?: string;
|
||||||
|
tasks: Task[];
|
||||||
|
openTaskDetail: (task: Task) => void;
|
||||||
|
renderTaskCard: (task: Task) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginDashboardViewComponentProps {
|
||||||
|
context: DependencyGraphHostContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Position = { x: number; y: number };
|
||||||
|
|
||||||
|
function getDistance(a: Position, b: Position): number {
|
||||||
|
const deltaX = a.x - b.x;
|
||||||
|
const deltaY = a.y - b.y;
|
||||||
|
return Math.hypot(deltaX, deltaY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DependencyGraphView({ context }: PluginDashboardViewComponentProps) {
|
||||||
|
const [scale, setScale] = useState(1);
|
||||||
|
const [pan, setPan] = useState<Position>({ x: 0, y: 0 });
|
||||||
|
const [nodeOverrides, setNodeOverrides] = useState<Record<string, Position>>({});
|
||||||
|
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||||
|
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
|
||||||
|
const persisted = useMemo(() => loadPositions(context.projectId), [context.projectId]);
|
||||||
|
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const interactionRef = useRef<
|
||||||
|
| { kind: "node"; taskId: string; startPointer: Position; startNode: Position; moved: boolean }
|
||||||
|
| { kind: "pan"; startPointer: Position; startPan: Position; moved: boolean }
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const tasks = useMemo(
|
||||||
|
() => context.tasks.filter((task) => ACTIVE_COLUMNS.has(task.column)),
|
||||||
|
[context.tasks],
|
||||||
|
);
|
||||||
|
|
||||||
|
const positioned = useMemo(() => {
|
||||||
|
return tasks.map((task, index) => {
|
||||||
|
const saved = nodeOverrides[task.id] ?? persisted[task.id];
|
||||||
|
return {
|
||||||
|
task,
|
||||||
|
x: saved?.x ?? (index % 4) * (NODE_WIDTH_REM + GRID_GAP_X_REM),
|
||||||
|
y: saved?.y ?? Math.floor(index / 4) * (NODE_HEIGHT_REM + GRID_GAP_Y_REM),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [nodeOverrides, persisted, tasks]);
|
||||||
|
|
||||||
|
const map = useMemo(() => new Map(positioned.map((node) => [node.task.id, node])), [positioned]);
|
||||||
|
|
||||||
|
const edges = useMemo(() => {
|
||||||
|
const lines: Array<{ from: string; to: string; x1: number; y1: number; x2: number; y2: number }> = [];
|
||||||
|
positioned.forEach((node) => {
|
||||||
|
(node.task.dependencies ?? []).forEach((dependencyId) => {
|
||||||
|
const dependency = map.get(dependencyId);
|
||||||
|
if (!dependency) return;
|
||||||
|
lines.push({
|
||||||
|
from: dependencyId,
|
||||||
|
to: node.task.id,
|
||||||
|
x1: dependency.x + NODE_WIDTH_REM,
|
||||||
|
y1: dependency.y + NODE_HEIGHT_REM / 2,
|
||||||
|
x2: node.x,
|
||||||
|
y2: node.y + NODE_HEIGHT_REM / 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return lines;
|
||||||
|
}, [map, positioned]);
|
||||||
|
|
||||||
|
const bounds = useMemo(() => {
|
||||||
|
if (positioned.length === 0) {
|
||||||
|
return { minX: 0, minY: 0, width: NODE_WIDTH_REM * 2, height: NODE_HEIGHT_REM * 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const minX = Math.min(...positioned.map((node) => node.x)) - SCENE_PADDING_REM;
|
||||||
|
const minY = Math.min(...positioned.map((node) => node.y)) - SCENE_PADDING_REM;
|
||||||
|
const maxX = Math.max(...positioned.map((node) => node.x + NODE_WIDTH_REM)) + SCENE_PADDING_REM;
|
||||||
|
const maxY = Math.max(...positioned.map((node) => node.y + NODE_HEIGHT_REM)) + SCENE_PADDING_REM;
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX,
|
||||||
|
minY,
|
||||||
|
width: Math.max(NODE_WIDTH_REM * 2, maxX - minX),
|
||||||
|
height: Math.max(NODE_HEIGHT_REM * 2, maxY - minY),
|
||||||
|
};
|
||||||
|
}, [positioned]);
|
||||||
|
|
||||||
|
const positionedForRender = useMemo(
|
||||||
|
() =>
|
||||||
|
positioned.map((node) => ({
|
||||||
|
...node,
|
||||||
|
renderX: node.x - bounds.minX,
|
||||||
|
renderY: node.y - bounds.minY,
|
||||||
|
})),
|
||||||
|
[bounds.minX, bounds.minY, positioned],
|
||||||
|
);
|
||||||
|
|
||||||
|
const edgesForRender = useMemo(
|
||||||
|
() =>
|
||||||
|
edges.map((edge) => ({
|
||||||
|
...edge,
|
||||||
|
renderX1: edge.x1 - bounds.minX,
|
||||||
|
renderY1: edge.y1 - bounds.minY,
|
||||||
|
renderX2: edge.x2 - bounds.minX,
|
||||||
|
renderY2: edge.y2 - bounds.minY,
|
||||||
|
})),
|
||||||
|
[bounds.minX, bounds.minY, edges],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dependencyGraph = useMemo(() => {
|
||||||
|
const downstream = new Map<string, Set<string>>();
|
||||||
|
const upstream = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
edges.forEach((edge) => {
|
||||||
|
downstream.set(edge.from, (downstream.get(edge.from) ?? new Set<string>()).add(edge.to));
|
||||||
|
upstream.set(edge.to, (upstream.get(edge.to) ?? new Set<string>()).add(edge.from));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { downstream, upstream };
|
||||||
|
}, [edges]);
|
||||||
|
|
||||||
|
const focusTaskId = hoveredTaskId ?? selectedTaskId;
|
||||||
|
|
||||||
|
const relatedTaskIds = useMemo(() => {
|
||||||
|
if (!focusTaskId) return null;
|
||||||
|
|
||||||
|
const related = new Set<string>([focusTaskId]);
|
||||||
|
const walk = (seed: string, map: Map<string, Set<string>>) => {
|
||||||
|
const queue = [seed];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift();
|
||||||
|
if (!current) continue;
|
||||||
|
(map.get(current) ?? new Set<string>()).forEach((next) => {
|
||||||
|
if (related.has(next)) return;
|
||||||
|
related.add(next);
|
||||||
|
queue.push(next);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(focusTaskId, dependencyGraph.downstream);
|
||||||
|
walk(focusTaskId, dependencyGraph.upstream);
|
||||||
|
|
||||||
|
return related;
|
||||||
|
}, [dependencyGraph.downstream, dependencyGraph.upstream, focusTaskId]);
|
||||||
|
|
||||||
|
const fitToGraph = () => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const rootFontSize = Number.parseFloat(globalThis.getComputedStyle(document.documentElement).fontSize) || 16;
|
||||||
|
const widthPx = bounds.width * rootFontSize;
|
||||||
|
const heightPx = bounds.height * rootFontSize;
|
||||||
|
const paddingPx = FIT_PADDING_REM * rootFontSize;
|
||||||
|
const availableWidth = Math.max(1, canvas.clientWidth - paddingPx * 2);
|
||||||
|
const availableHeight = Math.max(1, canvas.clientHeight - paddingPx * 2);
|
||||||
|
|
||||||
|
const nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, Math.min(availableWidth / widthPx, availableHeight / heightPx)));
|
||||||
|
const centeredPanX = (canvas.clientWidth - widthPx * nextScale) / (2 * rootFontSize * nextScale);
|
||||||
|
const centeredPanY = (canvas.clientHeight - heightPx * nextScale) / (2 * rootFontSize * nextScale);
|
||||||
|
|
||||||
|
setScale(nextScale);
|
||||||
|
setPan({ x: centeredPanX, y: centeredPanY });
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistPosition = (taskId: string, next: Position) => {
|
||||||
|
setNodeOverrides((current) => ({ ...current, [taskId]: next }));
|
||||||
|
savePositions(context.projectId, { ...persisted, ...nodeOverrides, [taskId]: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerDownOnNode = (taskId: string, event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
setSelectedTaskId((current) => (current === taskId ? null : taskId));
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const hit = map.get(taskId);
|
||||||
|
if (!hit) return;
|
||||||
|
interactionRef.current = {
|
||||||
|
kind: "node",
|
||||||
|
taskId,
|
||||||
|
startPointer: { x: event.clientX, y: event.clientY },
|
||||||
|
startNode: { x: hit.x, y: hit.y },
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanvasPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
interactionRef.current = {
|
||||||
|
kind: "pan",
|
||||||
|
startPointer: { x: event.clientX, y: event.clientY },
|
||||||
|
startPan: pan,
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
const current = interactionRef.current;
|
||||||
|
if (!current) return;
|
||||||
|
|
||||||
|
const delta = {
|
||||||
|
x: (event.clientX - current.startPointer.x) / 16,
|
||||||
|
y: (event.clientY - current.startPointer.y) / 16,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (current.kind === "node") {
|
||||||
|
const moved = getDistance({ x: 0, y: 0 }, delta) > DRAG_THRESHOLD_REM;
|
||||||
|
if (moved && !current.moved) current.moved = true;
|
||||||
|
if (!current.moved) return;
|
||||||
|
setNodeOverrides((existing) => ({
|
||||||
|
...existing,
|
||||||
|
[current.taskId]: { x: current.startNode.x + delta.x / scale, y: current.startNode.y + delta.y / scale },
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const moved = getDistance({ x: 0, y: 0 }, delta) > DRAG_THRESHOLD_REM;
|
||||||
|
if (moved && !current.moved) current.moved = true;
|
||||||
|
if (!current.moved) return;
|
||||||
|
setPan({ x: current.startPan.x + delta.x, y: current.startPan.y + delta.y });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
const current = interactionRef.current;
|
||||||
|
interactionRef.current = null;
|
||||||
|
if (!current) return;
|
||||||
|
|
||||||
|
if (current.kind === "node") {
|
||||||
|
const hit = map.get(current.taskId);
|
||||||
|
if (!hit) return;
|
||||||
|
|
||||||
|
if (!current.moved) {
|
||||||
|
context.openTaskDetail(hit.task);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
persistPosition(current.taskId, { x: hit.x, y: hit.y });
|
||||||
|
}
|
||||||
|
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="dependency-graph-view">
|
||||||
|
<div className="dependency-graph-controls">
|
||||||
|
<button className="btn btn-sm" onClick={() => setScale((value) => Math.min(value + 0.1, MAX_SCALE))}>Zoom In</button>
|
||||||
|
<button className="btn btn-sm" onClick={() => setScale((value) => Math.max(value - 0.1, MIN_SCALE))}>Zoom Out</button>
|
||||||
|
<button className="btn btn-sm" onClick={fitToGraph}>Fit</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="dependency-graph-canvas"
|
||||||
|
ref={canvasRef}
|
||||||
|
onPointerDown={handleCanvasPointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="dependency-graph-scene"
|
||||||
|
style={{
|
||||||
|
width: `${bounds.width}rem`,
|
||||||
|
height: `${bounds.height}rem`,
|
||||||
|
transform: `translate(${pan.x}rem, ${pan.y}rem) scale(${scale})`,
|
||||||
|
transformOrigin: "top left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg className="dependency-graph-edges" viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
|
||||||
|
{edgesForRender.map((edge) => (
|
||||||
|
<line
|
||||||
|
key={`${edge.from}-${edge.to}`}
|
||||||
|
x1={edge.renderX1}
|
||||||
|
y1={edge.renderY1}
|
||||||
|
x2={edge.renderX2}
|
||||||
|
y2={edge.renderY2}
|
||||||
|
className={`dependency-graph-edge${relatedTaskIds ? relatedTaskIds.has(edge.from) && relatedTaskIds.has(edge.to) ? " is-related" : " is-dimmed" : ""}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{positionedForRender.map((node) => (
|
||||||
|
<div
|
||||||
|
key={node.task.id}
|
||||||
|
className={`dependency-graph-node${selectedTaskId === node.task.id ? " is-selected" : ""}${relatedTaskIds ? relatedTaskIds.has(node.task.id) ? " is-related" : " is-dimmed" : ""}`}
|
||||||
|
style={{
|
||||||
|
width: `${NODE_WIDTH_REM}rem`,
|
||||||
|
minHeight: `${NODE_HEIGHT_REM}rem`,
|
||||||
|
transform: `translate(${node.renderX}rem, ${node.renderY}rem)`,
|
||||||
|
}}
|
||||||
|
onPointerDown={(event) => handlePointerDownOnNode(node.task.id, event)}
|
||||||
|
onPointerEnter={() => setHoveredTaskId(node.task.id)}
|
||||||
|
onPointerLeave={() => setHoveredTaskId((current) => (current === node.task.id ? null : current))}
|
||||||
|
>
|
||||||
|
{context.renderTaskCard(node.task)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it, beforeEach } from "vitest";
|
||||||
|
import { projectScopedKey, loadPositions, savePositions } from "../storage";
|
||||||
|
|
||||||
|
const createMemoryStorage = () => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => map.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
map.set(key, value);
|
||||||
|
},
|
||||||
|
clear: () => map.clear(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("storage", () => {
|
||||||
|
const localStorage = createMemoryStorage();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as { window?: { localStorage?: typeof localStorage } }).window = { localStorage };
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds project-scoped key", () => {
|
||||||
|
expect(projectScopedKey("proj_123")).toBe("kb:proj_123:dependency-graph-positions");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists and restores positions", () => {
|
||||||
|
savePositions("proj_123", { "FN-1": { x: 10, y: 20 } });
|
||||||
|
expect(loadPositions("proj_123")).toEqual({ "FN-1": { x: 10, y: 20 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
25
plugins/fusion-plugin-dependency-graph/src/index.ts
Normal file
25
plugins/fusion-plugin-dependency-graph/src/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { definePlugin } from "@fusion/plugin-sdk";
|
||||||
|
|
||||||
|
const plugin = definePlugin({
|
||||||
|
manifest: {
|
||||||
|
id: "fusion-plugin-dependency-graph",
|
||||||
|
name: "Dependency Graph",
|
||||||
|
version: "0.1.0",
|
||||||
|
description: "Top-level dependency graph dashboard view",
|
||||||
|
},
|
||||||
|
state: "installed",
|
||||||
|
hooks: {},
|
||||||
|
dashboardViews: [
|
||||||
|
{
|
||||||
|
viewId: "graph",
|
||||||
|
label: "Graph",
|
||||||
|
componentPath: "./src/DependencyGraphView.tsx",
|
||||||
|
icon: "Network",
|
||||||
|
placement: "more",
|
||||||
|
order: 40,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
|
export { DependencyGraphView } from "./DependencyGraphView";
|
||||||
23
plugins/fusion-plugin-dependency-graph/src/storage.ts
Normal file
23
plugins/fusion-plugin-dependency-graph/src/storage.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const BASE_KEY = "dependency-graph-positions";
|
||||||
|
|
||||||
|
export function projectScopedKey(projectId?: string): string {
|
||||||
|
const suffix = projectId ?? "default";
|
||||||
|
return `kb:${suffix}:${BASE_KEY}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPositions(projectId?: string): Record<string, { x: number; y: number }> {
|
||||||
|
if (typeof window === "undefined") return {};
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(projectScopedKey(projectId));
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, { x: number; y: number }>;
|
||||||
|
return parsed ?? {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePositions(projectId: string | undefined, positions: Record<string, { x: number; y: number }>): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.localStorage.setItem(projectScopedKey(projectId), JSON.stringify(positions));
|
||||||
|
}
|
||||||
13
plugins/fusion-plugin-dependency-graph/tsconfig.json
Normal file
13
plugins/fusion-plugin-dependency-graph/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["react"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
"exclude": ["src/__tests__/**"]
|
||||||
|
}
|
||||||
28
pnpm-lock.yaml
generated
28
pnpm-lock.yaml
generated
@@ -172,6 +172,9 @@ importers:
|
|||||||
'@codemirror/view':
|
'@codemirror/view':
|
||||||
specifier: ^6.36.4
|
specifier: ^6.36.4
|
||||||
version: 6.40.0
|
version: 6.40.0
|
||||||
|
'@fusion-plugin-examples/dependency-graph':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../plugins/fusion-plugin-dependency-graph
|
||||||
'@fusion-plugin-examples/hermes-runtime':
|
'@fusion-plugin-examples/hermes-runtime':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../plugins/fusion-plugin-hermes-runtime
|
version: link:../../plugins/fusion-plugin-hermes-runtime
|
||||||
@@ -547,6 +550,31 @@ importers:
|
|||||||
specifier: ^3.2.4
|
specifier: ^3.2.4
|
||||||
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3)
|
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||||
|
|
||||||
|
plugins/fusion-plugin-dependency-graph:
|
||||||
|
dependencies:
|
||||||
|
'@fusion/core':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/core
|
||||||
|
'@fusion/plugin-sdk':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/plugin-sdk
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^25.5.2
|
||||||
|
version: 25.5.2
|
||||||
|
'@types/react':
|
||||||
|
specifier: ^19.0.0
|
||||||
|
version: 19.2.14
|
||||||
|
react:
|
||||||
|
specifier: ^19.0.0
|
||||||
|
version: 19.2.4
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.0
|
||||||
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^3.2.4
|
||||||
|
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||||
|
|
||||||
plugins/fusion-plugin-hermes-runtime:
|
plugins/fusion-plugin-hermes-runtime:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fusion/plugin-sdk':
|
'@fusion/plugin-sdk':
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ packages:
|
|||||||
- "packages/*"
|
- "packages/*"
|
||||||
- "plugins/examples/*"
|
- "plugins/examples/*"
|
||||||
- "plugins/fusion-plugin-paperclip-runtime"
|
- "plugins/fusion-plugin-paperclip-runtime"
|
||||||
|
- "plugins/fusion-plugin-dependency-graph"
|
||||||
- "plugins/fusion-plugin-openclaw-runtime"
|
- "plugins/fusion-plugin-openclaw-runtime"
|
||||||
- "plugins/fusion-plugin-hermes-runtime"
|
- "plugins/fusion-plugin-hermes-runtime"
|
||||||
|
|||||||
Reference in New Issue
Block a user