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:
@@ -1177,6 +1177,7 @@ describe("PluginLoader", () => {
|
||||
expect(slots).toHaveLength(1);
|
||||
expect(slots[0].pluginId).toBe("slots-a");
|
||||
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.componentPath).toBe("./components/TaskDetailTab.js");
|
||||
});
|
||||
@@ -1235,6 +1236,52 @@ describe("PluginLoader", () => {
|
||||
"header-action",
|
||||
);
|
||||
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 () => {
|
||||
|
||||
@@ -708,12 +708,30 @@ describe("PluginUiSlotDefinition", () => {
|
||||
label: "Task Details",
|
||||
icon: "FileText",
|
||||
componentPath: "./components/TaskDetailTab.js",
|
||||
surface: "task-detail-tab",
|
||||
order: 5,
|
||||
placement: "after-default",
|
||||
};
|
||||
|
||||
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");
|
||||
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", () => {
|
||||
|
||||
@@ -139,6 +139,7 @@ export type {
|
||||
PluginToolResult,
|
||||
PluginRouteDefinition,
|
||||
PluginRouteMethod,
|
||||
PluginUiSurface,
|
||||
PluginUiSlotDefinition,
|
||||
PluginDashboardViewDefinition,
|
||||
PluginRuntimeManifestMetadata,
|
||||
|
||||
@@ -769,11 +769,24 @@ export class PluginLoader extends EventEmitter<{
|
||||
for (const [pluginId, plugin] of this.plugins) {
|
||||
if (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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -159,24 +159,48 @@ export interface PluginRouteDefinition {
|
||||
|
||||
// ── 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.
|
||||
* 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 {
|
||||
/** 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 */
|
||||
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;
|
||||
/** 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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,20 +7,19 @@ interface PluginSlotProps {
|
||||
slotId: string;
|
||||
/** Optional project ID for multi-project slot scoping */
|
||||
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
|
||||
* errors, or when no plugins are registered for the slot. Each rendered slot
|
||||
* is wrapped in an ErrorBoundary to isolate plugin rendering failures from the
|
||||
* parent dashboard UI.
|
||||
*
|
||||
* Future iterations will replace placeholder divs with dynamically loaded
|
||||
* components via the plugin's componentPath.
|
||||
* Dynamic plugin component loading is not yet available, so this renders a
|
||||
* meaningful fallback shell with plugin metadata instead of empty placeholders.
|
||||
* Each rendered slot is wrapped in an ErrorBoundary to isolate failures from
|
||||
* the parent dashboard UI.
|
||||
*/
|
||||
export function PluginSlot({ slotId, projectId }: PluginSlotProps): ReactNode {
|
||||
export function PluginSlot({ slotId, projectId, pluginIds }: PluginSlotProps): ReactNode {
|
||||
const { getSlotsForId, loading, error } = usePluginUiSlots(projectId);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
const matchingEntries = getSlotsForId(slotId);
|
||||
const matchingEntries = getSlotsForId(slotId).filter((entry) =>
|
||||
pluginIds && pluginIds.length > 0 ? pluginIds.includes(entry.pluginId) : true,
|
||||
);
|
||||
|
||||
if (matchingEntries.length === 0) {
|
||||
return null;
|
||||
@@ -37,15 +38,29 @@ export function PluginSlot({ slotId, projectId }: PluginSlotProps): ReactNode {
|
||||
return (
|
||||
<ErrorBoundary level="page">
|
||||
<>
|
||||
{matchingEntries.map((entry) => (
|
||||
<div
|
||||
key={`${entry.pluginId}-${entry.slot.slotId}`}
|
||||
{matchingEntries.map((entry, index) => (
|
||||
<section
|
||||
key={`${entry.pluginId}-${entry.slot.slotId}-${index}`}
|
||||
className="card"
|
||||
data-plugin-slot
|
||||
data-slot-id={entry.slot.slotId}
|
||||
data-plugin-id={entry.pluginId}
|
||||
data-component-path={entry.slot.componentPath}
|
||||
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>
|
||||
|
||||
@@ -407,7 +407,7 @@
|
||||
}
|
||||
|
||||
.settings-plugins-subsection-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
background: var(--surface-hover, color-mix(in srgb, var(--text) 6%, transparent));
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
|
||||
@@ -444,6 +444,14 @@ export function TaskDetailModal({
|
||||
// Plugin UI slots for task-detail-tab
|
||||
const { getSlotsForId: getPluginSlots } = usePluginUiSlots(projectId);
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -1712,13 +1720,12 @@ export function TaskDetailModal({
|
||||
Routing
|
||||
</button>
|
||||
{/* Plugin tabs */}
|
||||
{pluginTabSlots.map((entry, index) => {
|
||||
const pluginTabId = `plugin-${index}` as TabId;
|
||||
{pluginTabs.map(({ entry, tabId }) => {
|
||||
return (
|
||||
<button
|
||||
key={`plugin-tab-${entry.pluginId}`}
|
||||
className={`detail-tab${activeTab === pluginTabId ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab(pluginTabId)}
|
||||
key={`plugin-tab-${entry.pluginId}-${tabId}`}
|
||||
className={`detail-tab${activeTab === tabId ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab(tabId)}
|
||||
>
|
||||
{entry.slot.label}
|
||||
</button>
|
||||
@@ -1812,9 +1819,13 @@ export function TaskDetailModal({
|
||||
onTaskUpdated={onTaskUpdated}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
) : typeof activeTab === "string" && activeTab.startsWith("plugin-") ? (
|
||||
) : activePluginTab ? (
|
||||
<div className="detail-section">
|
||||
<PluginSlot slotId="task-detail-tab" projectId={projectId} />
|
||||
<PluginSlot
|
||||
slotId="task-detail-tab"
|
||||
projectId={projectId}
|
||||
pluginIds={[activePluginTab.entry.pluginId]}
|
||||
/>
|
||||
</div>
|
||||
) : activeTab === "stats" ? (
|
||||
<div className="detail-section">
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("PluginSlot", () => {
|
||||
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");
|
||||
vi.mocked(usePluginUiSlots).mockReturnValue({
|
||||
slots: [entry],
|
||||
@@ -49,16 +49,18 @@ describe("PluginSlot", () => {
|
||||
|
||||
const { container } = render(<PluginSlot slotId="task-detail-tab" />);
|
||||
|
||||
const divs = container.querySelectorAll("[data-plugin-slot]");
|
||||
expect(divs).toHaveLength(1);
|
||||
const div = divs[0];
|
||||
expect(div).toHaveAttribute("data-slot-id", "task-detail-tab");
|
||||
expect(div).toHaveAttribute("data-plugin-id", "plugin-a");
|
||||
expect(div).toHaveAttribute("data-component-path", "./components/task-detail-tab.js");
|
||||
expect(div).toHaveAttribute("aria-label", "Test slot task-detail-tab");
|
||||
const shells = container.querySelectorAll("[data-plugin-slot]");
|
||||
expect(shells).toHaveLength(1);
|
||||
const shell = shells[0];
|
||||
expect(shell).toHaveAttribute("data-slot-id", "task-detail-tab");
|
||||
expect(shell).toHaveAttribute("data-plugin-id", "plugin-a");
|
||||
expect(shell).toHaveAttribute("data-component-path", "./components/task-detail-tab.js");
|
||||
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 entryB = createSlotEntry("board-column-footer", "plugin-y");
|
||||
vi.mocked(usePluginUiSlots).mockReturnValue({
|
||||
@@ -70,14 +72,14 @@ describe("PluginSlot", () => {
|
||||
|
||||
const { container } = render(<PluginSlot slotId="board-column-footer" />);
|
||||
|
||||
const divs = container.querySelectorAll("[data-plugin-slot]");
|
||||
expect(divs).toHaveLength(2);
|
||||
const shells = container.querySelectorAll("[data-plugin-slot]");
|
||||
expect(shells).toHaveLength(2);
|
||||
|
||||
// Verify both divs have correct attributes
|
||||
expect(divs[0]).toHaveAttribute("data-plugin-id", "plugin-x");
|
||||
expect(divs[0]).toHaveAttribute("data-slot-id", "board-column-footer");
|
||||
expect(divs[1]).toHaveAttribute("data-plugin-id", "plugin-y");
|
||||
expect(divs[1]).toHaveAttribute("data-slot-id", "board-column-footer");
|
||||
// Verify both shells have correct attributes
|
||||
expect(shells[0]).toHaveAttribute("data-plugin-id", "plugin-x");
|
||||
expect(shells[0]).toHaveAttribute("data-slot-id", "board-column-footer");
|
||||
expect(shells[1]).toHaveAttribute("data-plugin-id", "plugin-y");
|
||||
expect(shells[1]).toHaveAttribute("data-slot-id", "board-column-footer");
|
||||
});
|
||||
|
||||
it("returns null when loading", () => {
|
||||
@@ -132,7 +134,20 @@ describe("PluginSlot", () => {
|
||||
expect(getSlotsForId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// NOTE: Error boundary testing should be added when dynamic component loading
|
||||
// is implemented. The ErrorBoundary wraps the rendered divs and catches any
|
||||
// rendering errors from future plugin components.
|
||||
it("filters rendered slots by pluginIds when provided", () => {
|
||||
const entryA = createSlotEntry("task-detail-tab", "plugin-a");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6191,13 +6191,15 @@ describe("TaskDetailModal", () => {
|
||||
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({
|
||||
slots: [
|
||||
{ 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" ? [
|
||||
{ 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,
|
||||
error: null,
|
||||
@@ -6215,13 +6217,12 @@ describe("TaskDetailModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// Click the plugin tab
|
||||
await userEvent.click(screen.getByText("Plugin A Tab"));
|
||||
await userEvent.click(screen.getByText("Plugin B Tab"));
|
||||
|
||||
// Verify plugin slot renders with task-detail-tab slotId
|
||||
const slot = container.querySelector('[data-slot-id="task-detail-tab"]');
|
||||
expect(slot).not.toBeNull();
|
||||
expect(slot).toHaveAttribute("data-plugin-id", "plugin-a");
|
||||
const slots = container.querySelectorAll('[data-slot-id="task-detail-tab"]');
|
||||
expect(slots).toHaveLength(1);
|
||||
expect(slots[0]).toHaveAttribute("data-plugin-id", "plugin-b");
|
||||
expect(container.querySelector('[data-plugin-id="plugin-a"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders no extra tabs when no plugins register", () => {
|
||||
|
||||
@@ -769,6 +769,7 @@ describe("GET /api/plugins/ui-slots", () => {
|
||||
slotId: "task-detail-tab",
|
||||
label: "Task Details",
|
||||
componentPath: "./components/TaskDetailTab.js",
|
||||
order: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -778,6 +779,7 @@ describe("GET /api/plugins/ui-slots", () => {
|
||||
label: "Header Action",
|
||||
icon: "Plus",
|
||||
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");
|
||||
|
||||
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");
|
||||
expect(res.body[0].slot.slotId).toBe("header-action");
|
||||
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 () => {
|
||||
@@ -817,6 +821,8 @@ describe("GET /api/plugins/ui-slots", () => {
|
||||
expect(res.body[0].slot).toHaveProperty("slotId");
|
||||
expect(res.body[0].slot).toHaveProperty("label");
|
||||
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 () => {
|
||||
|
||||
@@ -3028,7 +3028,23 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
*/
|
||||
router.get("/plugins/ui-slots", async (_req: Request, res: Response) => {
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ export type {
|
||||
PluginToolResult,
|
||||
PluginRouteDefinition,
|
||||
PluginRouteMethod,
|
||||
PluginUiSurface,
|
||||
PluginUiSlotDefinition,
|
||||
PluginDashboardViewDefinition,
|
||||
PluginRuntimeManifestMetadata,
|
||||
|
||||
Reference in New Issue
Block a user