feat(FN-3154): add plugin schema hooks, nav placement, and progress preserv

This merge introduces three major features: task reset now preserves existing progress with explicit user confirmation (FN-3185), the engine's soft-pause/unpause behavior is restored and documented (FN-3201), and the plugin system gains schema hook aggregation with new navigation placement and icons

Fusion-Task-Id: FN-3154
This commit is contained in:
Fusion
2026-05-02 10:41:22 -07:00
committed by gsxdsm
parent 44cc899eb9
commit bed7f3d325
12 changed files with 269 additions and 16 deletions

View File

@@ -274,12 +274,32 @@ const plugin: FusionPlugin = {
| `onTaskMoved` | `(task: Task, fromColumn: string, toColumn: string, ctx: PluginContext) => Promise<void> \| void` | Task moved between columns | | `onTaskMoved` | `(task: Task, fromColumn: string, toColumn: string, ctx: PluginContext) => Promise<void> \| void` | Task moved between columns |
| `onTaskCompleted` | `(task: Task, ctx: PluginContext) => Promise<void> \| void` | Task reached "done" | | `onTaskCompleted` | `(task: Task, ctx: PluginContext) => Promise<void> \| void` | Task reached "done" |
| `onError` | `(error: Error, ctx: PluginContext) => Promise<void> \| void` | Error occurred in plugin execution | | `onError` | `(error: Error, ctx: PluginContext) => Promise<void> \| void` | Error occurred in plugin execution |
| `onSchemaInit` | `(db: Database) => Promise<void> \| void` | During DB schema initialization (before core migrations complete) |
### Hook Behavior ### Hook Behavior
- **Timeout**: 5 seconds per invocation (logged and skipped if exceeded) - **Timeout**: 5 seconds per invocation (logged and skipped if exceeded)
- **Error Isolation**: Hook failures never block the host system - **Error Isolation**: Hook failures never block the host system
- **Optional**: Only define the hooks you need - **Optional**: Only define the hooks you need
- **Schema hook constraints**: `onSchemaInit` is intended for idempotent DDL only (`CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`). Avoid data backfills or long-running logic.
### Example: Schema initialization hook
```typescript
hooks: {
onSchemaInit: async (db) => {
db.exec(`
CREATE TABLE IF NOT EXISTS plugin_roadmaps (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_plugin_roadmaps_created_at
ON plugin_roadmaps(created_at);
`);
},
},
```
### Example: Notification on Task Completion ### Example: Notification on Task Completion
@@ -505,11 +525,24 @@ const dashboardViews: PluginDashboardViewDefinition[] = [
componentPath: "./src/DependencyGraphView.tsx", componentPath: "./src/DependencyGraphView.tsx",
icon: "Network", icon: "Network",
order: 40, order: 40,
placement: "more", placement: "overflow",
description: "Explore task dependency links",
}, },
]; ];
``` ```
### PluginDashboardViewDefinition fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `viewId` | `string` | Yes | Unique slug-like ID within your plugin namespace |
| `label` | `string` | Yes | Human-readable nav label |
| `componentPath` | `string` | Yes | Module path for the view component, relative to plugin root |
| `icon` | `string` | No | Lucide icon name |
| `order` | `number` | No | Lower values appear earlier in nav |
| `placement` | `"primary" \| "overflow" \| "more"` | No | Navigation placement hint (default is host-defined overflow behavior) |
| `description` | `string` | No | Short summary for nav/help surfaces |
Current host constraints: Current host constraints:
- Discovery API: `GET /api/plugins/dashboard-views` - Discovery API: `GET /api/plugins/dashboard-views`
- The dashboard **does not eval or filesystem-load plugin code in-browser** - The dashboard **does not eval or filesystem-load plugin code in-browser**

View File

@@ -1344,6 +1344,26 @@ describe("PluginLoader", () => {
expect(loader.getPluginDashboardViews()).toEqual([]); expect(loader.getPluginDashboardViews()).toEqual([]);
}); });
it("returns aggregated views from a single plugin", 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: {},
dashboardViews: [
{ viewId: "graph", label: "Graph", componentPath: "./graph.js", placement: "more" },
{ viewId: "timeline", label: "Timeline", componentPath: "./timeline.js", placement: "overflow" },
],
} as FusionPlugin);
const views = loader.getPluginDashboardViews();
expect(views.map((entry) => entry.pluginId + ":" + entry.view.viewId)).toEqual([
"views-a:graph",
"views-a:timeline",
]);
});
it("aggregates dashboard views from multiple plugins and keeps uiSlots separate", async () => { it("aggregates dashboard views from multiple plugins and keeps uiSlots separate", async () => {
await pluginStore.init(); await pluginStore.init();
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore }); const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
@@ -1371,6 +1391,48 @@ describe("PluginLoader", () => {
}); });
}); });
describe("getPluginSchemaInitHooks", () => {
it("returns empty array when no plugins define onSchemaInit", async () => {
await pluginStore.init();
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
(loader as any).plugins.set("no-hook", {
manifest: makeManifest({ id: "no-hook" }),
state: "started",
hooks: {},
} as FusionPlugin);
expect(loader.getPluginSchemaInitHooks()).toEqual([]);
});
it("returns hooks only from plugins that define onSchemaInit", async () => {
await pluginStore.init();
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
const hookA = async () => {};
const hookB = () => {};
(loader as any).plugins.set("schema-a", {
manifest: makeManifest({ id: "schema-a" }),
state: "started",
hooks: { onSchemaInit: hookA },
} as FusionPlugin);
(loader as any).plugins.set("schema-b", {
manifest: makeManifest({ id: "schema-b" }),
state: "started",
hooks: { onLoad: async () => {} },
} as FusionPlugin);
(loader as any).plugins.set("schema-c", {
manifest: makeManifest({ id: "schema-c" }),
state: "started",
hooks: { onSchemaInit: hookB },
} as FusionPlugin);
const hooks = loader.getPluginSchemaInitHooks();
expect(hooks.map((entry) => entry.pluginId)).toEqual(["schema-a", "schema-c"]);
expect(hooks[0]?.hook).toBe(hookA);
expect(hooks[1]?.hook).toBe(hookB);
});
});
// ── getPluginRuntimes ───────────────────────────────────────────── // ── getPluginRuntimes ─────────────────────────────────────────────
describe("getPluginRuntimes", () => { describe("getPluginRuntimes", () => {

View File

@@ -779,6 +779,24 @@ describe("PluginUiSlotDefinition", () => {
// ── FusionPlugin with uiSlots ────────────────────────────────────────── // ── FusionPlugin with uiSlots ──────────────────────────────────────────
describe("PluginDashboardViewDefinition", () => {
it("accepts a valid PluginDashboardViewDefinition with optional fields", () => {
const view = {
viewId: "roadmap-planner",
label: "Roadmap Planner",
componentPath: "./views/RoadmapPlanner.js",
icon: "Map",
order: 10,
placement: "overflow",
description: "Plan milestones and slices",
};
expect(view.viewId).toBe("roadmap-planner");
expect(view.placement).toBe("overflow");
expect(view.description).toContain("milestones");
});
});
describe("FusionPlugin with uiSlots", () => { describe("FusionPlugin with uiSlots", () => {
it("accepts a FusionPlugin with uiSlots array", () => { it("accepts a FusionPlugin with uiSlots array", () => {
const plugin = { const plugin = {
@@ -818,6 +836,27 @@ describe("FusionPlugin with uiSlots", () => {
expect((plugin as any).uiSlots).toBeUndefined(); expect((plugin as any).uiSlots).toBeUndefined();
}); });
it("accepts a FusionPlugin with dashboardViews and onSchemaInit hook", () => {
const plugin: FusionPlugin = {
manifest: { id: "test-plugin", name: "Test Plugin", version: "1.0.0" },
state: "started",
hooks: {
onSchemaInit: async () => {},
},
dashboardViews: [
{
viewId: "dependencies",
label: "Dependencies",
componentPath: "./views/Dependencies.js",
placement: "primary",
},
],
};
expect(plugin.hooks.onSchemaInit).toBeTypeOf("function");
expect(plugin.dashboardViews?.[0]?.viewId).toBe("dependencies");
});
}); });
// ── FusionPlugin with runtime ────────────────────────────────────────── // ── FusionPlugin with runtime ──────────────────────────────────────────

View File

@@ -131,6 +131,7 @@ export type {
PluginSettingType, PluginSettingType,
PluginOnLoad, PluginOnLoad,
PluginOnUnload, PluginOnUnload,
PluginOnSchemaInit,
PluginOnTaskCreated, PluginOnTaskCreated,
PluginOnTaskMoved, PluginOnTaskMoved,
PluginOnTaskCompleted, PluginOnTaskCompleted,

View File

@@ -23,6 +23,7 @@ import type {
PluginRouteDefinition, PluginRouteDefinition,
PluginUiSlotDefinition, PluginUiSlotDefinition,
PluginDashboardViewDefinition, PluginDashboardViewDefinition,
PluginOnSchemaInit,
PluginRuntimeRegistration, PluginRuntimeRegistration,
PluginInstallation, PluginInstallation,
PluginSkillContribution, PluginSkillContribution,
@@ -805,6 +806,19 @@ export class PluginLoader extends EventEmitter<{
return views; return views;
} }
/**
* Get all schema initialization hooks from loaded plugins.
*/
getPluginSchemaInitHooks(): Array<{ pluginId: string; hook: PluginOnSchemaInit }> {
const hooks: Array<{ pluginId: string; hook: PluginOnSchemaInit }> = [];
for (const [pluginId, plugin] of this.plugins) {
if (plugin.hooks.onSchemaInit) {
hooks.push({ pluginId, hook: plugin.hooks.onSchemaInit });
}
}
return hooks;
}
/** /**
* 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.

View File

@@ -11,6 +11,7 @@
* - PluginInstallation: persisted plugin record * - PluginInstallation: persisted plugin record
*/ */
import type { Database } from "./db.js";
import type { TaskStore } from "./store.js"; import type { TaskStore } from "./store.js";
import type { Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js"; import type { Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js";
@@ -108,6 +109,8 @@ export interface PluginLogger {
export type PluginOnLoad = (ctx: PluginContext) => Promise<void> | void; export type PluginOnLoad = (ctx: PluginContext) => Promise<void> | void;
/** Lifecycle hook: called when plugin is unloaded */ /** Lifecycle hook: called when plugin is unloaded */
export type PluginOnUnload = () => Promise<void> | void; export type PluginOnUnload = () => Promise<void> | void;
/** Lifecycle hook: called during database schema initialization */
export type PluginOnSchemaInit = (db: Database) => Promise<void> | void;
/** Lifecycle hook: called when a task is created */ /** Lifecycle hook: called when a task is created */
export type PluginOnTaskCreated = (task: Task, ctx: PluginContext) => Promise<void> | void; export type PluginOnTaskCreated = (task: Task, ctx: PluginContext) => Promise<void> | void;
/** Lifecycle hook: called when a task moves between columns */ /** Lifecycle hook: called when a task moves between columns */
@@ -222,7 +225,9 @@ export interface PluginDashboardViewDefinition {
/** Optional sort order for nav presentation. Lower numbers appear first. */ /** Optional sort order for nav presentation. Lower numbers appear first. */
order?: number; order?: number;
/** Preferred navigation placement for this top-level view. */ /** Preferred navigation placement for this top-level view. */
placement?: "primary" | "more"; placement?: "primary" | "overflow" | "more";
/** Optional short description used by navigation/help UI. */
description?: string;
} }
// ── Plugin Runtimes ───────────────────────────────────────────────── // ── Plugin Runtimes ─────────────────────────────────────────────────
@@ -398,6 +403,7 @@ export interface FusionPlugin {
onTaskMoved?: PluginOnTaskMoved; onTaskMoved?: PluginOnTaskMoved;
onTaskCompleted?: PluginOnTaskCompleted; onTaskCompleted?: PluginOnTaskCompleted;
onError?: PluginOnError; onError?: PluginOnError;
onSchemaInit?: PluginOnSchemaInit;
}; };
tools?: PluginToolDefinition[]; tools?: PluginToolDefinition[];
routes?: PluginRouteDefinition[]; routes?: PluginRouteDefinition[];

View File

@@ -14,6 +14,7 @@ import { getTrailingPath } from "../utils/pathDisplay";
import type { TaskView } from "../hooks/useViewState"; import type { TaskView } from "../hooks/useViewState";
import type { PluginDashboardViewEntry } from "../api"; import type { PluginDashboardViewEntry } from "../api";
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry"; import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
import { getPluginNavIcon } from "./pluginNavIcon";
export { useViewportMode }; export { useViewportMode };
@@ -1119,6 +1120,26 @@ export function Header({
> >
<Mail size={16} /> <Mail size={16} />
</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);
const PluginIcon = getPluginNavIcon(entry.view.icon);
return (
<button
key={`${entry.pluginId}:${entry.view.viewId}`}
className={`view-toggle-btn${view === pluginTaskView ? " active" : ""}`}
onClick={() => onChangeView(pluginTaskView)}
title={`${entry.view.label} view`}
aria-label={`${entry.view.label} view`}
aria-pressed={view === pluginTaskView}
data-testid={`view-toggle-plugin-${entry.pluginId}-${entry.view.viewId}`}
>
<PluginIcon size={16} />
</button>
);
})}
{hasViewOverflowItems && ( {hasViewOverflowItems && (
<> <>
<button <button
@@ -1244,6 +1265,7 @@ export function Header({
.sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER)) .sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER))
.map((entry) => { .map((entry) => {
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId); const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
const PluginIcon = getPluginNavIcon(entry.view.icon);
return ( return (
<button <button
key={`${entry.pluginId}:${entry.view.viewId}`} key={`${entry.pluginId}:${entry.view.viewId}`}
@@ -1255,7 +1277,7 @@ export function Header({
role="menuitem" role="menuitem"
data-testid={`view-overflow-plugin-${entry.pluginId}-${entry.view.viewId}`} data-testid={`view-overflow-plugin-${entry.pluginId}-${entry.view.viewId}`}
> >
<Grid3X3 size={14} /> <PluginIcon size={14} />
<span>{entry.view.label}</span> <span>{entry.view.label}</span>
</button> </button>
); );

View File

@@ -34,6 +34,7 @@ import type { PluginDashboardViewEntry } from "../api";
import { useViewportMode } from "./Header"; import { useViewportMode } from "./Header";
import type { TaskView } from "../hooks/useViewState"; import type { TaskView } from "../hooks/useViewState";
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry"; import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
import { getPluginNavIcon } from "./pluginNavIcon";
export interface MobileNavBarProps { export interface MobileNavBarProps {
/** Current task view mode */ /** Current task view mode */
@@ -196,6 +197,9 @@ export function MobileNavBar({
const showRoadmapsTopLevel = roadmapEnabled && (!skillsEnabled || view === "roadmaps"); const showRoadmapsTopLevel = roadmapEnabled && (!skillsEnabled || view === "roadmaps");
const showSkillsTopLevel = skillsEnabled && (!roadmapEnabled || view !== "roadmaps"); const showSkillsTopLevel = skillsEnabled && (!roadmapEnabled || view !== "roadmaps");
const showSkillsInMore = skillsEnabled && !showSkillsTopLevel; const showSkillsInMore = skillsEnabled && !showSkillsTopLevel;
const primaryPluginViews = pluginDashboardViews
.filter((entry) => entry.view.placement === "primary")
.sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER));
const isMoreActive = const isMoreActive =
view === "documents" view === "documents"
@@ -207,7 +211,7 @@ export function MobileNavBar({
|| (todosOpen && todoViewEnabled) || (todosOpen && todoViewEnabled)
|| (view === "roadmaps" && !showRoadmapsTopLevel) || (view === "roadmaps" && !showRoadmapsTopLevel)
|| (view === "skills" && !showSkillsTopLevel) || (view === "skills" && !showSkillsTopLevel)
|| view.startsWith("plugin:"); || (view.startsWith("plugin:") && !primaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
return ( return (
<> <>
@@ -315,6 +319,25 @@ export function MobileNavBar({
</button> </button>
)} )}
{primaryPluginViews.map((entry) => {
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
const PluginIcon = getPluginNavIcon(entry.view.icon);
return (
<button
key={`${entry.pluginId}:${entry.view.viewId}`}
type="button"
className={`mobile-nav-tab${view === pluginTaskView ? " mobile-nav-tab--active" : ""}`}
data-testid={`mobile-nav-tab-plugin-${entry.pluginId}-${entry.view.viewId}`}
role="tab"
aria-selected={view === pluginTaskView}
onClick={() => onChangeView(pluginTaskView)}
>
<PluginIcon />
<span className="mobile-nav-tab-label">{entry.view.label}</span>
</button>
);
})}
<button <button
type="button" type="button"
className={`mobile-nav-tab${isMoreActive ? " mobile-nav-tab--active" : ""}`} className={`mobile-nav-tab${isMoreActive ? " mobile-nav-tab--active" : ""}`}
@@ -641,6 +664,7 @@ export function MobileNavBar({
.sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER)) .sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER))
.map((entry) => { .map((entry) => {
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId); const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
const PluginIcon = getPluginNavIcon(entry.view.icon);
return ( return (
<button <button
key={`${entry.pluginId}:${entry.view.viewId}`} key={`${entry.pluginId}:${entry.view.viewId}`}
@@ -649,7 +673,7 @@ export function MobileNavBar({
data-testid={`mobile-more-item-plugin-${entry.pluginId}-${entry.view.viewId}`} data-testid={`mobile-more-item-plugin-${entry.pluginId}-${entry.view.viewId}`}
onClick={() => handleMoreAction(() => onChangeView(pluginTaskView))} onClick={() => handleMoreAction(() => onChangeView(pluginTaskView))}
> >
<Grid3X3 /> <PluginIcon />
<span>{entry.view.label}</span> <span>{entry.view.label}</span>
</button> </button>
); );

View File

@@ -157,23 +157,32 @@ 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", () => { it("renders plugin dashboard views by placement and uses manifest icon metadata", () => {
const onChangeView = vi.fn(); const onChangeView = vi.fn();
renderHeader({ renderHeader({
onChangeView, onChangeView,
pluginDashboardViews: [ pluginDashboardViews: [
{ {
pluginId: "fusion-plugin-dependency-graph", pluginId: "fusion-plugin-dependency-graph",
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView" }, view: { viewId: "graph", label: "Graph", componentPath: "./GraphView", icon: "Map", placement: "primary" },
},
{
pluginId: "fusion-plugin-dependency-graph",
view: { viewId: "queue", label: "Queue", componentPath: "./QueueView", icon: "Workflow" },
}, },
], ],
}); });
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger")); const graphPrimary = screen.getByTestId("view-toggle-plugin-fusion-plugin-dependency-graph-graph");
const graphItem = screen.getByTestId("view-overflow-plugin-fusion-plugin-dependency-graph-graph"); expect(graphPrimary.querySelector(".lucide-map")).toBeTruthy();
expect(graphItem).toBeInTheDocument(); fireEvent.click(graphPrimary);
fireEvent.click(graphItem);
expect(onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:graph"); expect(onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:graph");
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
const queueItem = screen.getByTestId("view-overflow-plugin-fusion-plugin-dependency-graph-queue");
expect(queueItem.querySelector(".lucide-workflow")).toBeTruthy();
fireEvent.click(queueItem);
expect(onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:queue");
}); });
it("renders view overflow trigger when an experimental overflow feature is enabled", () => { it("renders view overflow trigger when an experimental overflow feature is enabled", () => {

View File

@@ -104,7 +104,7 @@ 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", () => { it("renders primary plugin dashboard views as top-level tabs and keeps overflow views in More", () => {
const props = createDefaultProps(); const props = createDefaultProps();
render( render(
<MobileNavBar <MobileNavBar
@@ -112,16 +112,26 @@ describe("MobileNavBar", () => {
pluginDashboardViews={[ pluginDashboardViews={[
{ {
pluginId: "fusion-plugin-dependency-graph", pluginId: "fusion-plugin-dependency-graph",
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView" }, view: { viewId: "graph", label: "Graph", componentPath: "./GraphView", icon: "Map", placement: "primary" },
},
{
pluginId: "fusion-plugin-dependency-graph",
view: { viewId: "queue", label: "Queue", componentPath: "./QueueView", icon: "Workflow" },
}, },
]} ]}
/>, />,
); );
expect(screen.queryByTestId("mobile-nav-tab-graph")).toBeNull(); const primaryTab = screen.getByTestId("mobile-nav-tab-plugin-fusion-plugin-dependency-graph-graph");
fireEvent.click(screen.getByTestId("mobile-nav-tab-more")); expect(primaryTab.querySelector(".lucide-map")).toBeTruthy();
fireEvent.click(screen.getByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-graph")); fireEvent.click(primaryTab);
expect(props.onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:graph"); expect(props.onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:graph");
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
const overflowItem = screen.getByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-queue");
expect(overflowItem.querySelector(".lucide-workflow")).toBeTruthy();
fireEvent.click(overflowItem);
expect(props.onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:queue");
}); });
it("active tab is highlighted for mailbox", () => { it("active tab is highlighted for mailbox", () => {

View File

@@ -0,0 +1,32 @@
import { Activity, Bot, Brain, CheckSquare, Clock, FileText, Folder, GitBranch, Grid3X3, LayoutGrid, Mail, Map, MessageSquare, Monitor, Search, Sparkles, Target, Workflow, Zap } from "lucide-react";
import type { LucideIcon } from "lucide-react";
const PLUGIN_NAV_ICON_MAP: Record<string, LucideIcon> = {
activity: Activity,
bot: Bot,
brain: Brain,
checksquare: CheckSquare,
clock: Clock,
filetext: FileText,
folder: Folder,
gitbranch: GitBranch,
grid3x3: Grid3X3,
layoutgrid: LayoutGrid,
mail: Mail,
map: Map,
messagesquare: MessageSquare,
monitor: Monitor,
search: Search,
sparkles: Sparkles,
target: Target,
workflow: Workflow,
zap: Zap,
};
function normalizeIconName(iconName?: string): string {
return (iconName ?? "").trim().toLowerCase().replace(/[-_\s]/g, "");
}
export function getPluginNavIcon(iconName?: string): LucideIcon {
return PLUGIN_NAV_ICON_MAP[normalizeIconName(iconName)] ?? Grid3X3;
}

View File

@@ -40,6 +40,7 @@ export type {
PluginSettingType, PluginSettingType,
PluginOnLoad, PluginOnLoad,
PluginOnUnload, PluginOnUnload,
PluginOnSchemaInit,
PluginOnTaskCreated, PluginOnTaskCreated,
PluginOnTaskMoved, PluginOnTaskMoved,
PluginOnTaskCompleted, PluginOnTaskCompleted,