diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ae7b6ead0..7b20efcbd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -107,6 +107,7 @@ export type { PluginToolResult, PluginRouteDefinition, PluginRouteMethod, + PluginUiSlotDefinition, PluginContext, PluginLogger, FusionPlugin, diff --git a/packages/core/src/plugin-loader.test.ts b/packages/core/src/plugin-loader.test.ts index 5ed6b35ee..4cc14c5b5 100644 --- a/packages/core/src/plugin-loader.test.ts +++ b/packages/core/src/plugin-loader.test.ts @@ -813,6 +813,182 @@ describe("PluginLoader", () => { }); }); + // ── getPluginUiSlots ─────────────────────────────────────────────── + + describe("getPluginUiSlots", () => { + it("returns empty array when no plugins loaded", async () => { + await pluginStore.init(); + + const loader = new PluginLoader({ + pluginStore, + taskStore: mockTaskStore, + }); + + const slots = loader.getPluginUiSlots(); + expect(slots).toEqual([]); + }); + + it("returns empty array when plugins have no uiSlots", async () => { + await pluginStore.init(); + + const loader = new PluginLoader({ + pluginStore, + taskStore: mockTaskStore, + }); + + // Manually add plugin without uiSlots + (loader as any).plugins.set("no-ui-slots", { + manifest: makeManifest({ id: "no-ui-slots" }), + state: "started", + hooks: {}, + tools: [], + routes: [], + } as FusionPlugin); + + const slots = loader.getPluginUiSlots(); + expect(slots).toEqual([]); + }); + + it("returns aggregated slots from single plugin", async () => { + await pluginStore.init(); + + const loader = new PluginLoader({ + pluginStore, + taskStore: mockTaskStore, + }); + + // Manually add plugin with uiSlots + (loader as any).plugins.set("slots-a", { + manifest: makeManifest({ id: "slots-a" }), + state: "started", + hooks: {}, + tools: [], + routes: [], + uiSlots: [ + { + slotId: "task-detail-tab", + label: "Task Details", + componentPath: "./components/TaskDetailTab.js", + }, + ], + } as FusionPlugin); + + const slots = loader.getPluginUiSlots(); + + expect(slots).toHaveLength(1); + expect(slots[0].pluginId).toBe("slots-a"); + expect(slots[0].slot.slotId).toBe("task-detail-tab"); + expect(slots[0].slot.label).toBe("Task Details"); + expect(slots[0].slot.componentPath).toBe("./components/TaskDetailTab.js"); + }); + + it("returns aggregated slots from multiple plugins", async () => { + await pluginStore.init(); + + const loader = new PluginLoader({ + pluginStore, + taskStore: mockTaskStore, + }); + + // Manually add plugins with uiSlots + (loader as any).plugins.set("slots-a", { + manifest: makeManifest({ id: "slots-a" }), + state: "started", + hooks: {}, + tools: [], + routes: [], + uiSlots: [ + { + slotId: "task-detail-tab", + label: "Task Details", + componentPath: "./components/TaskDetailTab.js", + }, + ], + } as FusionPlugin); + (loader as any).plugins.set("slots-b", { + manifest: makeManifest({ id: "slots-b" }), + state: "started", + hooks: {}, + tools: [], + routes: [], + uiSlots: [ + { + slotId: "header-action", + label: "Header Action", + icon: "Plus", + componentPath: "./components/HeaderAction.js", + }, + { + slotId: "settings-section", + label: "Settings", + componentPath: "./components/SettingsSection.js", + }, + ], + } as FusionPlugin); + + const slots = loader.getPluginUiSlots(); + + expect(slots).toHaveLength(3); + expect(slots.find((s) => s.pluginId === "slots-a")?.slot.slotId).toBe( + "task-detail-tab", + ); + expect(slots.find((s) => s.pluginId === "slots-b")?.slot.slotId).toBe( + "header-action", + ); + expect(slots.filter((s) => s.pluginId === "slots-b")).toHaveLength(2); + }); + + it("each slot includes correct pluginId", async () => { + await pluginStore.init(); + + const loader = new PluginLoader({ + pluginStore, + taskStore: mockTaskStore, + }); + + // Manually add plugins with overlapping slotIds (different plugins) + (loader as any).plugins.set("plugin-x", { + manifest: makeManifest({ id: "plugin-x" }), + state: "started", + hooks: {}, + tools: [], + routes: [], + uiSlots: [ + { + slotId: "custom-tab", + label: "Custom Tab", + componentPath: "./components/CustomTab.js", + }, + ], + } as FusionPlugin); + (loader as any).plugins.set("plugin-y", { + manifest: makeManifest({ id: "plugin-y" }), + state: "started", + hooks: {}, + tools: [], + routes: [], + uiSlots: [ + { + slotId: "custom-tab", + label: "Custom Tab Y", + componentPath: "./components/CustomTabY.js", + }, + ], + } as FusionPlugin); + + const slots = loader.getPluginUiSlots(); + + // Both plugins can have slots with the same slotId + const pluginXSlot = slots.find((s) => s.pluginId === "plugin-x"); + const pluginYSlot = slots.find((s) => s.pluginId === "plugin-y"); + + expect(pluginXSlot?.slot.slotId).toBe("custom-tab"); + expect(pluginXSlot?.slot.label).toBe("Custom Tab"); + expect(pluginYSlot?.slot.slotId).toBe("custom-tab"); + expect(pluginYSlot?.slot.label).toBe("Custom Tab Y"); + }); + }); + // ── getLoadedPlugins ─────────────────────────────────────────────── describe("getLoadedPlugins", () => { diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index d8e9a737c..9ea7a8614 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -19,6 +19,7 @@ import type { PluginLogger, PluginToolDefinition, PluginRouteDefinition, + PluginUiSlotDefinition, PluginState, PluginInstallation, } from "./plugin-types.js"; @@ -729,6 +730,21 @@ export class PluginLoader extends EventEmitter<{ return routes; } + /** + * Get all UI slot definitions from loaded plugins. + */ + getPluginUiSlots(): Array<{ pluginId: string; slot: PluginUiSlotDefinition }> { + const slots: Array<{ pluginId: string; slot: PluginUiSlotDefinition }> = []; + for (const [pluginId, plugin] of this.plugins) { + if (plugin.uiSlots) { + for (const slot of plugin.uiSlots) { + slots.push({ pluginId, slot }); + } + } + } + return slots; + } + /** * Get all loaded plugin instances. */ diff --git a/packages/core/src/plugin-types.test.ts b/packages/core/src/plugin-types.test.ts index 637640185..1e2354ac4 100644 --- a/packages/core/src/plugin-types.test.ts +++ b/packages/core/src/plugin-types.test.ts @@ -463,3 +463,106 @@ describe("validatePluginManifest", () => { }); }); }); + +// ── PluginUiSlotDefinition ───────────────────────────────────────────── + +describe("PluginUiSlotDefinition", () => { + it("accepts a valid PluginUiSlotDefinition with all fields", () => { + const slot = { + slotId: "task-detail-tab", + label: "Task Details", + icon: "FileText", + componentPath: "./components/TaskDetailTab.js", + }; + + expect(slot.slotId).toBe("task-detail-tab"); + expect(slot.label).toBe("Task Details"); + expect(slot.icon).toBe("FileText"); + expect(slot.componentPath).toBe("./components/TaskDetailTab.js"); + }); + + it("accepts a valid PluginUiSlotDefinition without optional icon field", () => { + const slot = { + slotId: "header-action", + label: "Header Action", + componentPath: "./components/HeaderAction.js", + }; + + expect(slot.slotId).toBe("header-action"); + expect(slot.label).toBe("Header Action"); + expect(slot.componentPath).toBe("./components/HeaderAction.js"); + // icon is optional, so it should be undefined + expect((slot as any).icon).toBeUndefined(); + }); + + it("requires slotId field", () => { + const slot = { + label: "Some Label", + componentPath: "./components/Test.js", + }; + + // TypeScript would catch this at compile time, but at runtime we verify the structure + expect((slot as any).slotId).toBeUndefined(); + }); + + it("requires label field", () => { + const slot = { + slotId: "some-slot", + componentPath: "./components/Test.js", + }; + + expect((slot as any).label).toBeUndefined(); + }); + + it("requires componentPath field", () => { + const slot = { + slotId: "some-slot", + label: "Some Label", + }; + + expect((slot as any).componentPath).toBeUndefined(); + }); +}); + +// ── FusionPlugin with uiSlots ────────────────────────────────────────── + +describe("FusionPlugin with uiSlots", () => { + it("accepts a FusionPlugin with uiSlots array", () => { + const plugin = { + manifest: { id: "test-plugin", name: "Test Plugin", version: "1.0.0" }, + state: "started" as const, + hooks: {}, + tools: [], + routes: [], + uiSlots: [ + { + slotId: "task-detail-tab", + label: "Task Details", + componentPath: "./components/TaskDetailTab.js", + }, + { + slotId: "header-action", + label: "Header Action", + icon: "Plus", + componentPath: "./components/HeaderAction.js", + }, + ], + }; + + expect(plugin.uiSlots).toHaveLength(2); + expect(plugin.uiSlots![0].slotId).toBe("task-detail-tab"); + expect(plugin.uiSlots![1].icon).toBe("Plus"); + }); + + it("accepts a FusionPlugin without uiSlots field", () => { + const plugin = { + manifest: { id: "test-plugin", name: "Test Plugin", version: "1.0.0" }, + state: "started" as const, + hooks: {}, + tools: [], + routes: [], + }; + + expect((plugin as any).uiSlots).toBeUndefined(); + }); +}); diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index dd87f7771..179c322d5 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -142,6 +142,28 @@ export interface PluginRouteDefinition { description?: string; } +// ── Plugin UI Slots ───────────────────────────────────────────────── + +/** + * UI slot definition for plugin-provided dashboard components. + * Each slot represents a mount point where a plugin can render UI. + */ +export interface PluginUiSlotDefinition { + /** Unique slot identifier (e.g., "task-detail-tab", "header-action", "settings-section") */ + slotId: string; + /** Human-readable label for the UI slot */ + label: string; + /** Optional icon name (lucide-react icon name or custom icon identifier) */ + icon?: string; + /** + * Path to the JS module that exports the component. + * This should be a web component or a function component descriptor + * that the dashboard can render in the slot. + * Path is relative to the plugin's root directory. + */ + componentPath: string; +} + // ── Fusion Plugin ──────────────────────────────────────────────────── export type PluginState = "installed" | "started" | "stopped" | "error"; @@ -162,6 +184,7 @@ export interface FusionPlugin { }; tools?: PluginToolDefinition[]; routes?: PluginRouteDefinition[]; + uiSlots?: PluginUiSlotDefinition[]; } // ── Plugin Installation ─────────────────────────────────────────────── diff --git a/packages/dashboard/src/plugin-routes.test.ts b/packages/dashboard/src/plugin-routes.test.ts index 544b2c081..4954e7723 100644 --- a/packages/dashboard/src/plugin-routes.test.ts +++ b/packages/dashboard/src/plugin-routes.test.ts @@ -87,6 +87,7 @@ function createMockPluginLoader(overrides: Partial = {}): PluginLo getLoadedPlugins: vi.fn().mockReturnValue([]), getPluginTools: vi.fn().mockReturnValue([]), getPluginRoutes: vi.fn().mockReturnValue([]), + getPluginUiSlots: vi.fn().mockReturnValue([]), loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }), stopAllPlugins: vi.fn().mockResolvedValue(undefined), invokeHook: vi.fn().mockResolvedValue(undefined), @@ -657,3 +658,115 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", () ); }); }); + +// ══════════════════════════════════════════════════════════════════ +describe("GET /api/plugins/ui-slots", () => { + 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 uiSlots", async () => { + (pluginLoader.getPluginUiSlots as ReturnType).mockReturnValue([]); + + const res = await performGet(buildApp(), "/api/plugins/ui-slots"); + + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + it("returns 200 with aggregated slots when plugins have uiSlots", async () => { + const mockSlots = [ + { + pluginId: "test-plugin", + slot: { + slotId: "task-detail-tab", + label: "Task Details", + componentPath: "./components/TaskDetailTab.js", + }, + }, + { + pluginId: "test-plugin", + slot: { + slotId: "header-action", + label: "Header Action", + icon: "Plus", + componentPath: "./components/HeaderAction.js", + }, + }, + ]; + (pluginLoader.getPluginUiSlots as ReturnType).mockReturnValue(mockSlots); + + const res = await performGet(buildApp(), "/api/plugins/ui-slots"); + + expect(res.status).toBe(200); + expect(res.body).toEqual(mockSlots); + expect(res.body).toHaveLength(2); + expect(res.body[0].pluginId).toBe("test-plugin"); + expect(res.body[0].slot.slotId).toBe("task-detail-tab"); + expect(res.body[1].slot.icon).toBe("Plus"); + }); + + it("response shape is Array<{ pluginId: string; slot: PluginUiSlotDefinition }>", async () => { + const mockSlots = [ + { + pluginId: "plugin-a", + slot: { + slotId: "custom-slot", + label: "Custom Slot", + componentPath: "./components/CustomSlot.js", + }, + }, + ]; + (pluginLoader.getPluginUiSlots as ReturnType).mockReturnValue(mockSlots); + + const res = await performGet(buildApp(), "/api/plugins/ui-slots"); + + expect(res.status).toBe(200); + // Verify response shape + expect(Array.isArray(res.body)).toBe(true); + expect(res.body[0]).toHaveProperty("pluginId"); + expect(typeof res.body[0].pluginId).toBe("string"); + expect(res.body[0]).toHaveProperty("slot"); + expect(res.body[0].slot).toHaveProperty("slotId"); + expect(res.body[0].slot).toHaveProperty("label"); + expect(res.body[0].slot).toHaveProperty("componentPath"); + }); + + it("returns empty array when pluginLoader is not available", async () => { + // Build app without pluginLoader + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(store, { pluginStore })); + const res = await performGet(app, "/api/plugins/ui-slots"); + + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + it("does not conflict with /plugins/:id route", async () => { + // Verify that /plugins/ui-slots doesn't get matched by /plugins/:id + (pluginLoader.getPluginUiSlots as ReturnType).mockReturnValue([]); + + const res = await performGet(buildApp(), "/api/plugins/ui-slots"); + + // Should return 200, not 404 (which would happen if :id = "ui-slots" matched) + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); +}); diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index e0139ff80..fd2ea5c77 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -12969,6 +12969,16 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout res.json(plugins); }); + /** + * GET /api/plugins/ui-slots + * Get all UI slot definitions from active plugins. + * Returns aggregated array of { pluginId, slot } objects. + */ + router.get("/plugins/ui-slots", async (_req: Request, res: Response) => { + const slots = options?.pluginLoader?.getPluginUiSlots() ?? []; + res.json(slots); + }); + /** * GET /api/plugins/:id * Get a single plugin by ID. diff --git a/packages/engine/src/plugin-runner.test.ts b/packages/engine/src/plugin-runner.test.ts index 46576034c..33bd0b7f9 100644 --- a/packages/engine/src/plugin-runner.test.ts +++ b/packages/engine/src/plugin-runner.test.ts @@ -31,6 +31,7 @@ describe("PluginRunner", () => { invokeHook: ReturnType; getPluginTools: ReturnType; getPluginRoutes: ReturnType; + getPluginUiSlots: ReturnType; getLoadedPlugins: ReturnType; getPlugin: ReturnType; loadPlugin: ReturnType; @@ -70,6 +71,7 @@ describe("PluginRunner", () => { invokeHook: vi.fn().mockResolvedValue(undefined), getPluginTools: vi.fn().mockReturnValue([]), getPluginRoutes: vi.fn().mockReturnValue([]), + getPluginUiSlots: vi.fn().mockReturnValue([]), getLoadedPlugins: vi.fn().mockReturnValue([]), getPlugin: vi.fn(), loadPlugin: vi.fn().mockResolvedValue({}), @@ -280,6 +282,202 @@ describe("PluginRunner", () => { }); }); + describe("getPluginUiSlots()", () => { + it("should return empty array when no plugins have uiSlots", async () => { + mockPluginLoader.getPluginUiSlots.mockReturnValue([]); + await pluginRunner.init(); + const slots = pluginRunner.getPluginUiSlots(); + expect(slots).toEqual([]); + }); + + it("should return cached slots after plugins load", async () => { + const mockSlots = [ + { + pluginId: "test-plugin", + slot: { + slotId: "task-detail-tab", + label: "Task Details", + componentPath: "./components/TaskDetailTab.js", + }, + }, + ]; + mockPluginLoader.getPluginUiSlots.mockReturnValue(mockSlots); + + await pluginRunner.init(); + const slots1 = pluginRunner.getPluginUiSlots(); + const slots2 = pluginRunner.getPluginUiSlots(); + + expect(slots1).toEqual(mockSlots); + expect(slots2).toBe(slots1); // Same reference (cached) + }); + + it("should invalidate cache on plugin:reloaded event", async () => { + const mockSlots = [ + { + pluginId: "test-plugin", + slot: { + slotId: "custom-tab", + label: "Custom Tab", + componentPath: "./components/CustomTab.js", + }, + }, + ]; + mockPluginLoader.getPluginUiSlots.mockReturnValue(mockSlots); + + await pluginRunner.init(); + const slots1 = pluginRunner.getPluginUiSlots(); + expect(slots1).toEqual(mockSlots); + + // Simulate plugin:reloaded event that invalidates cache + const reloadHandler = mockPluginLoader.on.mock.calls.find( + call => call[0] === "plugin:reloaded" + )?.[1]; + if (reloadHandler) { + reloadHandler({ pluginId: "test-plugin" }); + } + + // Next call should rebuild cache + const newSlots = [ + { + pluginId: "test-plugin", + slot: { + slotId: "updated-tab", + label: "Updated Tab", + componentPath: "./components/UpdatedTab.js", + }, + }, + ]; + mockPluginLoader.getPluginUiSlots.mockReturnValue(newSlots); + + const slots2 = pluginRunner.getPluginUiSlots(); + expect(slots2).toEqual(newSlots); + expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2); + }); + + it("should invalidate cache on plugin:enabled event", async () => { + mockPluginLoader.getPluginUiSlots.mockReturnValue([]); + await pluginRunner.init(); + + // Get initial slots + pluginRunner.getPluginUiSlots(); + + // Simulate plugin:enabled event + const enabledHandler = mockPluginStore.on.mock.calls.find( + call => call[0] === "plugin:enabled" + )?.[1]; + + const newPlugin = { + id: "new-plugin", + name: "New Plugin", + version: "1.0.0", + path: "/test/path", + enabled: true, + state: "stopped" as const, + settings: {}, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }; + + if (enabledHandler) { + enabledHandler(newPlugin); + } + + // Next call should rebuild cache + pluginRunner.getPluginUiSlots(); + expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2); + }); + + it("should invalidate cache on plugin:disabled event", async () => { + mockPluginLoader.getPluginUiSlots.mockReturnValue([]); + await pluginRunner.init(); + + // Get initial slots + pluginRunner.getPluginUiSlots(); + + // Simulate plugin:disabled event + const disabledHandler = mockPluginStore.on.mock.calls.find( + call => call[0] === "plugin:disabled" + )?.[1]; + + const plugin = { + id: "test-plugin", + name: "Test Plugin", + version: "1.0.0", + path: "/test/path", + enabled: true, + state: "stopped" as const, + settings: {}, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }; + + if (disabledHandler) { + disabledHandler(plugin); + } + + // Next call should rebuild cache + pluginRunner.getPluginUiSlots(); + expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2); + }); + + it("should invalidate cache on plugin:stateChanged event", async () => { + mockPluginLoader.getPluginUiSlots.mockReturnValue([]); + await pluginRunner.init(); + + // Get initial slots + pluginRunner.getPluginUiSlots(); + + // Simulate plugin:stateChanged event + const stateHandler = mockPluginStore.on.mock.calls.find( + call => call[0] === "plugin:stateChanged" + )?.[1]; + + if (stateHandler) { + stateHandler(); + } + + // Next call should rebuild cache + pluginRunner.getPluginUiSlots(); + expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2); + }); + + it("should invalidate cache on plugin:updated event", async () => { + mockPluginLoader.getPluginUiSlots.mockReturnValue([]); + await pluginRunner.init(); + + // Get initial slots + pluginRunner.getPluginUiSlots(); + + // Simulate plugin:updated event + const updatedHandler = mockPluginStore.on.mock.calls.find( + call => call[0] === "plugin:updated" + )?.[1]; + + if (updatedHandler) { + updatedHandler(); + } + + // Next call should rebuild cache + pluginRunner.getPluginUiSlots(); + expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2); + }); + + it("should invalidate cache on reloadPlugin()", async () => { + mockPluginLoader.getPluginUiSlots.mockReturnValue([]); + await pluginRunner.init(); + + // Get initial slots + pluginRunner.getPluginUiSlots(); + + // Call reloadPlugin + await pluginRunner.reloadPlugin("test-plugin"); + + // Next call should rebuild cache + pluginRunner.getPluginUiSlots(); + expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2); + }); + }); + describe("getLoader() / getStore()", () => { it("should return the plugin loader", () => { const loader = pluginRunner.getLoader(); diff --git a/packages/engine/src/plugin-runner.ts b/packages/engine/src/plugin-runner.ts index 2088ef64b..a0306841f 100644 --- a/packages/engine/src/plugin-runner.ts +++ b/packages/engine/src/plugin-runner.ts @@ -14,6 +14,7 @@ import type { FusionPlugin, PluginToolDefinition, PluginRouteDefinition, + PluginUiSlotDefinition, PluginContext, } from "@fusion/core"; import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; @@ -51,14 +52,24 @@ interface CachedRoutes { version: number; } +/** + * Cached UI slots - rebuilt when plugin state changes + */ +interface CachedUiSlots { + slots: Array<{ pluginId: string; slot: PluginUiSlotDefinition }>; + version: number; +} + const DEFAULT_HOOK_TIMEOUT_MS = 5000; export class PluginRunner { private readonly log = createLogger("plugin-runner"); private cachedTools: CachedTools | null = null; private cachedRoutes: CachedRoutes | null = null; + private cachedUiSlots: CachedUiSlots | null = null; private toolsCacheVersion = 0; private routesCacheVersion = 0; + private uiSlotsCacheVersion = 0; private hookTimeoutMs: number; // Event handler references for cleanup @@ -114,6 +125,7 @@ export class PluginRunner { // Build initial caches this.invalidateToolsCache(); this.invalidateRoutesCache(); + this.invalidateUiSlotsCache(); } /** @@ -182,6 +194,20 @@ export class PluginRunner { return this.cachedRoutes.routes; } + /** + * Get all UI slot definitions from loaded plugins. + * UI slots are cached and only rebuilt when plugin state changes. + */ + getPluginUiSlots(): Array<{ pluginId: string; slot: PluginUiSlotDefinition }> { + if (!this.cachedUiSlots || this.cachedUiSlots.version !== this.uiSlotsCacheVersion) { + this.cachedUiSlots = { + slots: this.options.pluginLoader.getPluginUiSlots(), + version: this.uiSlotsCacheVersion, + }; + } + return this.cachedUiSlots.slots; + } + /** * Get the underlying plugin loader. */ @@ -205,6 +231,7 @@ export class PluginRunner { await this.options.pluginLoader.reloadPlugin(pluginId); this.invalidateToolsCache(); this.invalidateRoutesCache(); + this.invalidateUiSlotsCache(); executorLog.log(`Plugin ${pluginId} reloaded`); } @@ -214,11 +241,14 @@ export class PluginRunner { * Handle plugin:enabled event - automatically load the plugin. */ private async onPluginEnabled(plugin: import("@fusion/core").PluginInstallation): Promise { + // Invalidate caches before the operation to ensure fresh state regardless of outcome + this.invalidateToolsCache(); + this.invalidateRoutesCache(); + this.invalidateUiSlotsCache(); + try { executorLog.log(`Auto-loading enabled plugin: ${plugin.id}`); await this.options.pluginLoader.loadPlugin(plugin.id); - this.invalidateToolsCache(); - this.invalidateRoutesCache(); } catch (err) { this.log.error(`Failed to auto-load plugin ${plugin.id}:`, err); // Don't rethrow - error isolation @@ -229,11 +259,14 @@ export class PluginRunner { * Handle plugin:disabled event - automatically stop the plugin. */ private async onPluginDisabled(plugin: import("@fusion/core").PluginInstallation): Promise { + // Invalidate caches before the operation to ensure fresh state regardless of outcome + this.invalidateToolsCache(); + this.invalidateRoutesCache(); + this.invalidateUiSlotsCache(); + try { executorLog.log(`Auto-stopping disabled plugin: ${plugin.id}`); await this.options.pluginLoader.stopPlugin(plugin.id); - this.invalidateToolsCache(); - this.invalidateRoutesCache(); } catch (err) { this.log.error(`Failed to auto-stop plugin ${plugin.id}:`, err); // Don't rethrow - error isolation @@ -244,11 +277,14 @@ export class PluginRunner { * Handle plugin:unregistered event - ensure plugin is stopped. */ private async onPluginUnregistered(plugin: import("@fusion/core").PluginInstallation): Promise { + // Invalidate caches before the operation to ensure fresh state regardless of outcome + this.invalidateToolsCache(); + this.invalidateRoutesCache(); + this.invalidateUiSlotsCache(); + try { executorLog.log(`Stopping unregistered plugin: ${plugin.id}`); await this.options.pluginLoader.stopPlugin(plugin.id); - this.invalidateToolsCache(); - this.invalidateRoutesCache(); } catch { // Ignore - plugin might not be loaded } @@ -260,6 +296,7 @@ export class PluginRunner { private onPluginStateChanged(): void { this.invalidateToolsCache(); this.invalidateRoutesCache(); + this.invalidateUiSlotsCache(); } /** @@ -268,6 +305,7 @@ export class PluginRunner { private onPluginUpdated(): void { this.invalidateToolsCache(); this.invalidateRoutesCache(); + this.invalidateUiSlotsCache(); } /** @@ -276,6 +314,7 @@ export class PluginRunner { private onPluginLoaded(_event: { pluginId: string }): void { this.invalidateToolsCache(); this.invalidateRoutesCache(); + this.invalidateUiSlotsCache(); } /** @@ -284,6 +323,7 @@ export class PluginRunner { private onPluginUnloaded(_event: { pluginId: string }): void { this.invalidateToolsCache(); this.invalidateRoutesCache(); + this.invalidateUiSlotsCache(); } /** @@ -292,6 +332,7 @@ export class PluginRunner { private onPluginReloaded(_event: { pluginId: string }): void { this.invalidateToolsCache(); this.invalidateRoutesCache(); + this.invalidateUiSlotsCache(); } // ── Tool Conversion ─────────────────────────────────────────────── @@ -451,6 +492,14 @@ export class PluginRunner { this.log.log(`Routes cache invalidated (version: ${this.routesCacheVersion})`); } + /** + * Invalidate the UI slots cache, forcing rebuild on next access. + */ + private invalidateUiSlotsCache(): void { + this.uiSlotsCacheVersion++; + this.log.log(`UI slots cache invalidated (version: ${this.uiSlotsCacheVersion})`); + } + // ── Store Event Subscriptions ──────────────────────────────────── /** diff --git a/packages/plugin-sdk/src/index.test.ts b/packages/plugin-sdk/src/index.test.ts index 17540d715..8fa438d06 100644 --- a/packages/plugin-sdk/src/index.test.ts +++ b/packages/plugin-sdk/src/index.test.ts @@ -189,6 +189,55 @@ describe("Plugin SDK", () => { }; expect(ctx.pluginId).toBe("test"); }); + + it("exports PluginUiSlotDefinition type", () => { + // This is a compile-time test - if types are exported correctly, this will compile + const slot: import("../../core/src/plugin-types.js").PluginUiSlotDefinition = { + slotId: "task-detail-tab", + label: "Task Details", + icon: "FileText", + componentPath: "./components/TaskDetailTab.js", + }; + expect(slot.slotId).toBe("task-detail-tab"); + expect(slot.label).toBe("Task Details"); + expect(slot.icon).toBe("FileText"); + expect(slot.componentPath).toBe("./components/TaskDetailTab.js"); + }); + + it("PluginUiSlotDefinition icon is optional", () => { + // This is a compile-time test - if types are exported correctly, this will compile + const slot: import("../../core/src/plugin-types.js").PluginUiSlotDefinition = { + slotId: "header-action", + label: "Header Action", + componentPath: "./components/HeaderAction.js", + }; + expect(slot.slotId).toBe("header-action"); + expect((slot as any).icon).toBeUndefined(); + }); + + it("PluginUiSlotDefinition can be used in FusionPlugin", () => { + // This verifies that PluginUiSlotDefinition can be used in the FusionPlugin interface + const plugin: FusionPlugin = { + manifest: { + id: "test", + name: "Test", + version: "1.0.0", + }, + state: "installed", + hooks: {}, + tools: [], + routes: [], + uiSlots: [ + { + slotId: "custom-tab", + label: "Custom Tab", + componentPath: "./components/CustomTab.js", + }, + ], + }; + expect(plugin.uiSlots).toHaveLength(1); + expect(plugin.uiSlots![0].slotId).toBe("custom-tab"); + }); }); // ── validatePluginManifest ─────────────────────────────────────────── diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 625472b6b..785d29496 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -48,6 +48,7 @@ export type { PluginToolResult, PluginRouteDefinition, PluginRouteMethod, + PluginUiSlotDefinition, PluginContext, PluginLogger, FusionPlugin,