feat(FN-3066): extend plugin UI slot metadata and fix settings token fallba

This release (v0.15.0) introduces plugin dashboard views and a new dependency graph plugin, extends the plugin slot system with richer metadata surfaces and UX improvements, adds SQLite WAL tuning with integrity checks, and stabilizes agent run log streaming with enriched session labels. It also fix

Fusion-Task-Id: FN-3066
This commit is contained in:
Fusion
2026-05-01 23:44:44 -07:00
committed by gsxdsm
parent 1729f52d63
commit 39ba9ab0bd
13 changed files with 227 additions and 59 deletions

View File

@@ -1177,6 +1177,7 @@ describe("PluginLoader", () => {
expect(slots).toHaveLength(1); expect(slots).toHaveLength(1);
expect(slots[0].pluginId).toBe("slots-a"); expect(slots[0].pluginId).toBe("slots-a");
expect(slots[0].slot.slotId).toBe("task-detail-tab"); expect(slots[0].slot.slotId).toBe("task-detail-tab");
expect(slots[0].slot.surface).toBe("task-detail-tab");
expect(slots[0].slot.label).toBe("Task Details"); expect(slots[0].slot.label).toBe("Task Details");
expect(slots[0].slot.componentPath).toBe("./components/TaskDetailTab.js"); expect(slots[0].slot.componentPath).toBe("./components/TaskDetailTab.js");
}); });
@@ -1235,6 +1236,52 @@ describe("PluginLoader", () => {
"header-action", "header-action",
); );
expect(slots.filter((s) => s.pluginId === "slots-b")).toHaveLength(2); expect(slots.filter((s) => s.pluginId === "slots-b")).toHaveLength(2);
expect(slots.map((slot) => slot.pluginId)).toEqual(["slots-a", "slots-b", "slots-b"]);
});
it("sorts slots by order and then pluginId/slotId", async () => {
await pluginStore.init();
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
(loader as any).plugins.set("plugin-b", {
manifest: makeManifest({ id: "plugin-b" }),
state: "started",
hooks: {},
uiSlots: [
{
slotId: "onboarding-provider-card",
label: "B",
componentPath: "./B.js",
order: 10,
},
],
} as FusionPlugin);
(loader as any).plugins.set("plugin-a", {
manifest: makeManifest({ id: "plugin-a" }),
state: "started",
hooks: {},
uiSlots: [
{
slotId: "onboarding-provider-card",
label: "A-first",
componentPath: "./A.js",
order: 1,
},
{
slotId: "settings-section",
label: "A-second",
componentPath: "./A2.js",
},
],
} as FusionPlugin);
const slots = loader.getPluginUiSlots();
expect(slots.map((slot) => slot.slot.label)).toEqual(["A-first", "B", "A-second"]);
}); });
it("each slot includes correct pluginId", async () => { it("each slot includes correct pluginId", async () => {

View File

@@ -708,12 +708,30 @@ describe("PluginUiSlotDefinition", () => {
label: "Task Details", label: "Task Details",
icon: "FileText", icon: "FileText",
componentPath: "./components/TaskDetailTab.js", componentPath: "./components/TaskDetailTab.js",
surface: "task-detail-tab",
order: 5,
placement: "after-default",
}; };
expect(slot.slotId).toBe("task-detail-tab"); expect(slot.slotId).toBe("task-detail-tab");
expect(slot.label).toBe("Task Details"); expect(slot.label).toBe("Task Details");
expect(slot.icon).toBe("FileText"); expect(slot.icon).toBe("FileText");
expect(slot.componentPath).toBe("./components/TaskDetailTab.js"); expect(slot.componentPath).toBe("./components/TaskDetailTab.js");
expect(slot.surface).toBe("task-detail-tab");
expect(slot.order).toBe(5);
expect(slot.placement).toBe("after-default");
});
it("accepts new host-owned onboarding/settings surfaces", () => {
const slot = {
slotId: "onboarding-setup-help",
label: "Setup help",
componentPath: "./components/SetupHelp.js",
surface: "onboarding-setup-help",
};
expect(slot.slotId).toBe("onboarding-setup-help");
expect(slot.surface).toBe("onboarding-setup-help");
}); });
it("accepts a valid PluginUiSlotDefinition without optional icon field", () => { it("accepts a valid PluginUiSlotDefinition without optional icon field", () => {

View File

@@ -139,6 +139,7 @@ export type {
PluginToolResult, PluginToolResult,
PluginRouteDefinition, PluginRouteDefinition,
PluginRouteMethod, PluginRouteMethod,
PluginUiSurface,
PluginUiSlotDefinition, PluginUiSlotDefinition,
PluginDashboardViewDefinition, PluginDashboardViewDefinition,
PluginRuntimeManifestMetadata, PluginRuntimeManifestMetadata,

View File

@@ -769,11 +769,24 @@ export class PluginLoader extends EventEmitter<{
for (const [pluginId, plugin] of this.plugins) { for (const [pluginId, plugin] of this.plugins) {
if (plugin.uiSlots) { if (plugin.uiSlots) {
for (const slot of plugin.uiSlots) { for (const slot of plugin.uiSlots) {
slots.push({ pluginId, slot }); slots.push({
pluginId,
slot: {
...slot,
surface: slot.surface ?? (typeof slot.slotId === "string" ? slot.slotId as PluginUiSlotDefinition["surface"] : undefined),
},
});
} }
} }
} }
return slots;
return slots.sort((a, b) => {
const orderA = a.slot.order ?? Number.MAX_SAFE_INTEGER;
const orderB = b.slot.order ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) return orderA - orderB;
if (a.pluginId !== b.pluginId) return a.pluginId.localeCompare(b.pluginId);
return String(a.slot.slotId).localeCompare(String(b.slot.slotId));
});
} }

View File

@@ -159,24 +159,48 @@ export interface PluginRouteDefinition {
// ── Plugin UI Slots ───────────────────────────────────────────────── // ── Plugin UI Slots ─────────────────────────────────────────────────
/**
* Host-defined dashboard UI surfaces that plugins can contribute to.
* Existing generic surfaces remain supported for backward compatibility.
*/
export type PluginUiSurface =
| "header-action"
| "task-detail-tab"
| "task-card-badge"
| "board-column-footer"
| "settings-section"
| "settings-provider-card"
| "settings-integration-card"
| "onboarding-provider-card"
| "onboarding-recommendation-card"
| "onboarding-setup-help"
| "post-onboarding-recommendation";
/** /**
* UI slot definition for plugin-provided dashboard components. * UI slot definition for plugin-provided dashboard components.
* Each slot represents a mount point where a plugin can render UI. * Each slot represents a host-owned mount point where a plugin can render UI.
*/ */
export interface PluginUiSlotDefinition { export interface PluginUiSlotDefinition {
/** Unique slot identifier (e.g., "task-detail-tab", "header-action", "settings-section") */ /**
slotId: string; * Unique slot identifier. Should match one of the known host surfaces above,
* but string is retained for compatibility with legacy plugins.
*/
slotId: PluginUiSurface | string;
/** Human-readable label for the UI slot */ /** Human-readable label for the UI slot */
label: string; label: string;
/** Optional icon name (lucide-react icon name or custom icon identifier) */ /** Optional icon name (lucide-react icon name or custom icon identifier) */
icon?: string; icon?: string;
/** /**
* Path to the JS module that exports the component. * 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. * Path is relative to the plugin's root directory.
*/ */
componentPath: string; componentPath: string;
/** Optional explicit surface metadata (defaults to slotId). */
surface?: PluginUiSurface;
/** Optional deterministic render order; lower values render first. */
order?: number;
/** Optional host placement hint for the surface. */
placement?: "before-default" | "after-default" | "replace-default";
} }
/** /**

View File

@@ -7,20 +7,19 @@ interface PluginSlotProps {
slotId: string; slotId: string;
/** Optional project ID for multi-project slot scoping */ /** Optional project ID for multi-project slot scoping */
projectId?: string; projectId?: string;
/** Optional plugin IDs to restrict rendering to a subset of matching entries */
pluginIds?: string[];
} }
/** /**
* Renders placeholder divs for all plugin UI slots matching the given slotId. * Renders plugin slot registrations for a host surface.
* *
* This component is non-critical: it silently fails (returns null) for loading * Dynamic plugin component loading is not yet available, so this renders a
* errors, or when no plugins are registered for the slot. Each rendered slot * meaningful fallback shell with plugin metadata instead of empty placeholders.
* is wrapped in an ErrorBoundary to isolate plugin rendering failures from the * Each rendered slot is wrapped in an ErrorBoundary to isolate failures from
* parent dashboard UI. * the parent dashboard UI.
*
* Future iterations will replace placeholder divs with dynamically loaded
* components via the plugin's componentPath.
*/ */
export function PluginSlot({ slotId, projectId }: PluginSlotProps): ReactNode { export function PluginSlot({ slotId, projectId, pluginIds }: PluginSlotProps): ReactNode {
const { getSlotsForId, loading, error } = usePluginUiSlots(projectId); const { getSlotsForId, loading, error } = usePluginUiSlots(projectId);
// Non-critical failure — no visible UI when loading, errored, or no matching slots // Non-critical failure — no visible UI when loading, errored, or no matching slots
@@ -28,7 +27,9 @@ export function PluginSlot({ slotId, projectId }: PluginSlotProps): ReactNode {
return null; return null;
} }
const matchingEntries = getSlotsForId(slotId); const matchingEntries = getSlotsForId(slotId).filter((entry) =>
pluginIds && pluginIds.length > 0 ? pluginIds.includes(entry.pluginId) : true,
);
if (matchingEntries.length === 0) { if (matchingEntries.length === 0) {
return null; return null;
@@ -37,15 +38,29 @@ export function PluginSlot({ slotId, projectId }: PluginSlotProps): ReactNode {
return ( return (
<ErrorBoundary level="page"> <ErrorBoundary level="page">
<> <>
{matchingEntries.map((entry) => ( {matchingEntries.map((entry, index) => (
<div <section
key={`${entry.pluginId}-${entry.slot.slotId}`} key={`${entry.pluginId}-${entry.slot.slotId}-${index}`}
className="card"
data-plugin-slot data-plugin-slot
data-slot-id={entry.slot.slotId} data-slot-id={entry.slot.slotId}
data-plugin-id={entry.pluginId} data-plugin-id={entry.pluginId}
data-component-path={entry.slot.componentPath} data-component-path={entry.slot.componentPath}
aria-label={entry.slot.label} aria-label={entry.slot.label}
/> >
<div className="card-header">
<span className="card-title">{entry.slot.label}</span>
<span className="card-id">{entry.pluginId}</span>
</div>
<div className="card-meta">
<span className="detail-metadata-label">Surface</span>
<code className="detail-source-number">{entry.slot.slotId}</code>
</div>
<div className="detail-source-summary">
<span className="detail-source-label">Component</span>
<code className="detail-source-number">{entry.slot.componentPath}</code>
</div>
</section>
))} ))}
</> </>
</ErrorBoundary> </ErrorBoundary>

View File

@@ -407,7 +407,7 @@
} }
.settings-plugins-subsection-btn:hover { .settings-plugins-subsection-btn:hover {
background: var(--surface-hover); background: var(--surface-hover, color-mix(in srgb, var(--text) 6%, transparent));
color: var(--text); color: var(--text);
} }

View File

@@ -444,6 +444,14 @@ export function TaskDetailModal({
// Plugin UI slots for task-detail-tab // Plugin UI slots for task-detail-tab
const { getSlotsForId: getPluginSlots } = usePluginUiSlots(projectId); const { getSlotsForId: getPluginSlots } = usePluginUiSlots(projectId);
const pluginTabSlots = getPluginSlots("task-detail-tab"); const pluginTabSlots = getPluginSlots("task-detail-tab");
const pluginTabs = pluginTabSlots.map((entry, index) => ({
entry,
tabId: `plugin-${entry.pluginId}-${index}` as TabId,
}));
const activePluginTab =
typeof activeTab === "string" && activeTab.startsWith("plugin-")
? pluginTabs.find((tab) => tab.tabId === activeTab) ?? null
: null;
// Track mount state to avoid setting state on unmounted component // Track mount state to avoid setting state on unmounted component
useEffect(() => { useEffect(() => {
@@ -1712,13 +1720,12 @@ export function TaskDetailModal({
Routing Routing
</button> </button>
{/* Plugin tabs */} {/* Plugin tabs */}
{pluginTabSlots.map((entry, index) => { {pluginTabs.map(({ entry, tabId }) => {
const pluginTabId = `plugin-${index}` as TabId;
return ( return (
<button <button
key={`plugin-tab-${entry.pluginId}`} key={`plugin-tab-${entry.pluginId}-${tabId}`}
className={`detail-tab${activeTab === pluginTabId ? " detail-tab-active" : ""}`} className={`detail-tab${activeTab === tabId ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab(pluginTabId)} onClick={() => setActiveTab(tabId)}
> >
{entry.slot.label} {entry.slot.label}
</button> </button>
@@ -1812,9 +1819,13 @@ export function TaskDetailModal({
onTaskUpdated={onTaskUpdated} onTaskUpdated={onTaskUpdated}
canEdit={canEdit} canEdit={canEdit}
/> />
) : typeof activeTab === "string" && activeTab.startsWith("plugin-") ? ( ) : activePluginTab ? (
<div className="detail-section"> <div className="detail-section">
<PluginSlot slotId="task-detail-tab" projectId={projectId} /> <PluginSlot
slotId="task-detail-tab"
projectId={projectId}
pluginIds={[activePluginTab.entry.pluginId]}
/>
</div> </div>
) : activeTab === "stats" ? ( ) : activeTab === "stats" ? (
<div className="detail-section"> <div className="detail-section">

View File

@@ -38,7 +38,7 @@ describe("PluginSlot", () => {
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();
}); });
it("renders placeholder div for single matching slot", () => { it("renders fallback shell for single matching slot", () => {
const entry = createSlotEntry("task-detail-tab", "plugin-a"); const entry = createSlotEntry("task-detail-tab", "plugin-a");
vi.mocked(usePluginUiSlots).mockReturnValue({ vi.mocked(usePluginUiSlots).mockReturnValue({
slots: [entry], slots: [entry],
@@ -49,16 +49,18 @@ describe("PluginSlot", () => {
const { container } = render(<PluginSlot slotId="task-detail-tab" />); const { container } = render(<PluginSlot slotId="task-detail-tab" />);
const divs = container.querySelectorAll("[data-plugin-slot]"); const shells = container.querySelectorAll("[data-plugin-slot]");
expect(divs).toHaveLength(1); expect(shells).toHaveLength(1);
const div = divs[0]; const shell = shells[0];
expect(div).toHaveAttribute("data-slot-id", "task-detail-tab"); expect(shell).toHaveAttribute("data-slot-id", "task-detail-tab");
expect(div).toHaveAttribute("data-plugin-id", "plugin-a"); expect(shell).toHaveAttribute("data-plugin-id", "plugin-a");
expect(div).toHaveAttribute("data-component-path", "./components/task-detail-tab.js"); expect(shell).toHaveAttribute("data-component-path", "./components/task-detail-tab.js");
expect(div).toHaveAttribute("aria-label", "Test slot task-detail-tab"); expect(shell).toHaveAttribute("aria-label", "Test slot task-detail-tab");
expect(shell.textContent).toContain("Test slot task-detail-tab");
expect(shell.textContent).toContain("plugin-a");
}); });
it("renders multiple placeholders for multiple plugins registered for same slotId", () => { it("renders multiple fallback shells for multiple plugins registered for same slotId", () => {
const entryA = createSlotEntry("board-column-footer", "plugin-x"); const entryA = createSlotEntry("board-column-footer", "plugin-x");
const entryB = createSlotEntry("board-column-footer", "plugin-y"); const entryB = createSlotEntry("board-column-footer", "plugin-y");
vi.mocked(usePluginUiSlots).mockReturnValue({ vi.mocked(usePluginUiSlots).mockReturnValue({
@@ -70,14 +72,14 @@ describe("PluginSlot", () => {
const { container } = render(<PluginSlot slotId="board-column-footer" />); const { container } = render(<PluginSlot slotId="board-column-footer" />);
const divs = container.querySelectorAll("[data-plugin-slot]"); const shells = container.querySelectorAll("[data-plugin-slot]");
expect(divs).toHaveLength(2); expect(shells).toHaveLength(2);
// Verify both divs have correct attributes // Verify both shells have correct attributes
expect(divs[0]).toHaveAttribute("data-plugin-id", "plugin-x"); expect(shells[0]).toHaveAttribute("data-plugin-id", "plugin-x");
expect(divs[0]).toHaveAttribute("data-slot-id", "board-column-footer"); expect(shells[0]).toHaveAttribute("data-slot-id", "board-column-footer");
expect(divs[1]).toHaveAttribute("data-plugin-id", "plugin-y"); expect(shells[1]).toHaveAttribute("data-plugin-id", "plugin-y");
expect(divs[1]).toHaveAttribute("data-slot-id", "board-column-footer"); expect(shells[1]).toHaveAttribute("data-slot-id", "board-column-footer");
}); });
it("returns null when loading", () => { it("returns null when loading", () => {
@@ -132,7 +134,20 @@ describe("PluginSlot", () => {
expect(getSlotsForId).not.toHaveBeenCalled(); expect(getSlotsForId).not.toHaveBeenCalled();
}); });
// NOTE: Error boundary testing should be added when dynamic component loading it("filters rendered slots by pluginIds when provided", () => {
// is implemented. The ErrorBoundary wraps the rendered divs and catches any const entryA = createSlotEntry("task-detail-tab", "plugin-a");
// rendering errors from future plugin components. const entryB = createSlotEntry("task-detail-tab", "plugin-b");
vi.mocked(usePluginUiSlots).mockReturnValue({
slots: [entryA, entryB],
getSlotsForId: vi.fn(() => [entryA, entryB]),
loading: false,
error: null,
});
const { container } = render(<PluginSlot slotId="task-detail-tab" pluginIds={["plugin-b"]} />);
const shells = container.querySelectorAll("[data-plugin-slot]");
expect(shells).toHaveLength(1);
expect(shells[0]).toHaveAttribute("data-plugin-id", "plugin-b");
});
}); });

View File

@@ -6191,13 +6191,15 @@ describe("TaskDetailModal", () => {
expect(screen.getByText("Plugin B Tab")).toBeDefined(); expect(screen.getByText("Plugin B Tab")).toBeDefined();
}); });
it("shows plugin tab content when plugin tab is clicked", async () => { it("shows only the selected plugin tab content when plugin tab is clicked", async () => {
mockUsePluginUiSlots.mockReturnValue({ mockUsePluginUiSlots.mockReturnValue({
slots: [ slots: [
{ pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } }, { pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } },
{ pluginId: "plugin-b", slot: { slotId: "task-detail-tab", label: "Plugin B Tab", componentPath: "./b.js" } },
], ],
getSlotsForId: (id: string) => id === "task-detail-tab" ? [ getSlotsForId: (id: string) => id === "task-detail-tab" ? [
{ pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } }, { pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } },
{ pluginId: "plugin-b", slot: { slotId: "task-detail-tab", label: "Plugin B Tab", componentPath: "./b.js" } },
] : [], ] : [],
loading: false, loading: false,
error: null, error: null,
@@ -6215,13 +6217,12 @@ describe("TaskDetailModal", () => {
/> />
); );
// Click the plugin tab await userEvent.click(screen.getByText("Plugin B Tab"));
await userEvent.click(screen.getByText("Plugin A Tab"));
// Verify plugin slot renders with task-detail-tab slotId const slots = container.querySelectorAll('[data-slot-id="task-detail-tab"]');
const slot = container.querySelector('[data-slot-id="task-detail-tab"]'); expect(slots).toHaveLength(1);
expect(slot).not.toBeNull(); expect(slots[0]).toHaveAttribute("data-plugin-id", "plugin-b");
expect(slot).toHaveAttribute("data-plugin-id", "plugin-a"); expect(container.querySelector('[data-plugin-id="plugin-a"]')).toBeNull();
}); });
it("renders no extra tabs when no plugins register", () => { it("renders no extra tabs when no plugins register", () => {

View File

@@ -769,6 +769,7 @@ describe("GET /api/plugins/ui-slots", () => {
slotId: "task-detail-tab", slotId: "task-detail-tab",
label: "Task Details", label: "Task Details",
componentPath: "./components/TaskDetailTab.js", componentPath: "./components/TaskDetailTab.js",
order: 10,
}, },
}, },
{ {
@@ -778,6 +779,7 @@ describe("GET /api/plugins/ui-slots", () => {
label: "Header Action", label: "Header Action",
icon: "Plus", icon: "Plus",
componentPath: "./components/HeaderAction.js", componentPath: "./components/HeaderAction.js",
order: 1,
}, },
}, },
]; ];
@@ -786,11 +788,13 @@ describe("GET /api/plugins/ui-slots", () => {
const res = await performGet(buildApp(), "/api/plugins/ui-slots"); const res = await performGet(buildApp(), "/api/plugins/ui-slots");
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body).toEqual(mockSlots);
expect(res.body).toHaveLength(2); expect(res.body).toHaveLength(2);
expect(res.body[0].pluginId).toBe("test-plugin"); expect(res.body[0].pluginId).toBe("test-plugin");
expect(res.body[0].slot.slotId).toBe("task-detail-tab"); expect(res.body[0].slot.slotId).toBe("header-action");
expect(res.body[1].slot.icon).toBe("Plus"); expect(res.body[0].slot.surface).toBe("header-action");
expect(res.body[1].slot.slotId).toBe("task-detail-tab");
expect(res.body[1].slot.surface).toBe("task-detail-tab");
expect(res.body[1].slot.order).toBe(10);
}); });
it("response shape is Array<{ pluginId: string; slot: PluginUiSlotDefinition }>", async () => { it("response shape is Array<{ pluginId: string; slot: PluginUiSlotDefinition }>", async () => {
@@ -817,6 +821,8 @@ describe("GET /api/plugins/ui-slots", () => {
expect(res.body[0].slot).toHaveProperty("slotId"); expect(res.body[0].slot).toHaveProperty("slotId");
expect(res.body[0].slot).toHaveProperty("label"); expect(res.body[0].slot).toHaveProperty("label");
expect(res.body[0].slot).toHaveProperty("componentPath"); expect(res.body[0].slot).toHaveProperty("componentPath");
expect(res.body[0].slot).toHaveProperty("surface");
expect(res.body[0].slot).toHaveProperty("order");
}); });
it("returns empty array when pluginLoader is not available", async () => { it("returns empty array when pluginLoader is not available", async () => {

View File

@@ -3028,7 +3028,23 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
*/ */
router.get("/plugins/ui-slots", async (_req: Request, res: Response) => { router.get("/plugins/ui-slots", async (_req: Request, res: Response) => {
const slots = options?.pluginLoader?.getPluginUiSlots() ?? []; const slots = options?.pluginLoader?.getPluginUiSlots() ?? [];
res.json(slots); const normalizedSlots = slots
.map((entry) => ({
pluginId: entry.pluginId,
slot: {
...entry.slot,
surface: entry.slot.surface ?? (typeof entry.slot.slotId === "string" ? entry.slot.slotId : undefined),
order: entry.slot.order ?? null,
},
}))
.sort((a, b) => {
const orderA = typeof a.slot.order === "number" ? a.slot.order : Number.MAX_SAFE_INTEGER;
const orderB = typeof b.slot.order === "number" ? b.slot.order : Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) return orderA - orderB;
if (a.pluginId !== b.pluginId) return a.pluginId.localeCompare(b.pluginId);
return String(a.slot.slotId).localeCompare(String(b.slot.slotId));
});
res.json(normalizedSlots);
}); });

View File

@@ -48,6 +48,7 @@ export type {
PluginToolResult, PluginToolResult,
PluginRouteDefinition, PluginRouteDefinition,
PluginRouteMethod, PluginRouteMethod,
PluginUiSurface,
PluginUiSlotDefinition, PluginUiSlotDefinition,
PluginDashboardViewDefinition, PluginDashboardViewDefinition,
PluginRuntimeManifestMetadata, PluginRuntimeManifestMetadata,