From 3a4922a05930b0dc22740bf5621910803e8f275e Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 3 Sep 2026 18:05:29 +0900 Subject: feat(plugin-system): plan 07 frontend plugin manager ui --- src/features/plugins/ui/PluginManager.svelte | 217 ++++++++++++++++ src/features/plugins/ui/PluginManager.test.ts | 284 +++++++++++++++++++++ src/features/plugins/ui/PluginRow.svelte | 116 +++++++++ src/features/plugins/ui/PluginTable.svelte | 85 ++++++ src/features/plugins/ui/UnloadConfirmModal.svelte | 40 +++ .../plugins/ui/WorkspacePluginModal.svelte | 110 ++++++++ .../plugins/ui/WorkspacePluginModal.test.ts | 124 +++++++++ 7 files changed, 976 insertions(+) create mode 100644 src/features/plugins/ui/PluginManager.svelte create mode 100644 src/features/plugins/ui/PluginManager.test.ts create mode 100644 src/features/plugins/ui/PluginRow.svelte create mode 100644 src/features/plugins/ui/PluginTable.svelte create mode 100644 src/features/plugins/ui/UnloadConfirmModal.svelte create mode 100644 src/features/plugins/ui/WorkspacePluginModal.svelte create mode 100644 src/features/plugins/ui/WorkspacePluginModal.test.ts (limited to 'src/features/plugins/ui') diff --git a/src/features/plugins/ui/PluginManager.svelte b/src/features/plugins/ui/PluginManager.svelte new file mode 100644 index 0000000..cd3d542 --- /dev/null +++ b/src/features/plugins/ui/PluginManager.svelte @@ -0,0 +1,217 @@ + + + + +{#if pendingUnload !== null} + 0 + ? [ + `Unloading ${pendingUnload.node.id} also unchecks: ${pendingUnload.cascade.join(", ")}.`, + ] + : []), + ...(pendingUnload.notice !== null ? [pendingUnload.notice] : []), + ]} + confirmLabel={pendingUnload.cascade.length > 0 ? "Unload with force" : "Force unload"} + onCancel={cancelUnload} + onConfirm={confirmUnload} + /> +{/if} diff --git a/src/features/plugins/ui/PluginManager.test.ts b/src/features/plugins/ui/PluginManager.test.ts new file mode 100644 index 0000000..c81938c --- /dev/null +++ b/src/features/plugins/ui/PluginManager.test.ts @@ -0,0 +1,284 @@ +import type { PluginListResponse } from "@dispatch/transport-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { PluginOpResult, PluginsHttp } from "../adapter/http"; +import { createPluginsStore } from "../store.svelte"; +import PluginManager from "./PluginManager.svelte"; + +const listResponse: PluginListResponse = { + loaded: [ + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + { + id: "opencode", + name: "OpenCode", + version: "2.0.0", + tier: "providers", + trust: "bundled", + locked: false, + dependsOn: [], + }, + { + id: "transport-http", + name: "Transport HTTP", + version: "1.1.0", + tier: "standard", + trust: "bundled", + locked: false, + dependsOn: ["heartbeat"], + }, + { + id: "heartbeat", + name: "Heartbeat", + version: "1.1.0", + tier: "standard", + trust: "bundled", + locked: false, + dependsOn: [], + }, + ], + disabledWithReason: [ + { + id: "broken", + name: "Broken", + version: "0.1.0", + tier: "standard", + trust: "local", + reason: "activation threw", + }, + ], + discovered: [ + { + id: "fresh", + name: "Fresh", + version: "3.0.0", + tier: "standard", + trust: "external", + dir: "/plugins/fresh", + }, + ], + missing: [{ id: "gone", reason: "directory is gone" }], +}; + +/** A fake PluginsHttp (the OUTERMOST edge — the store itself is real). */ +function fakeHttp(overrides: Partial = {}): PluginsHttp { + return { + list: vi.fn(async () => listResponse), + scan: vi.fn(async () => ({ + ok: true, + response: { root: "/plugins", newPlugins: [], invalid: [], incompatible: [] }, + })), + load: vi.fn(async () => ({ ok: true, response: { ok: true, action: "load", id: "x" } })), + unload: vi.fn(async () => ({ ok: true, response: { ok: true, action: "unload", id: "x" } })), + reload: vi.fn(async () => ({ ok: true, response: { ok: true, action: "reload", id: "x" } })), + workspacePlugins: vi.fn(async () => null), + setWorkspaceState: vi.fn(async () => ({ + ok: true, + response: { workspaceId: "ws", pluginId: "x", state: "enabled" }, + })), + providersStatus: vi.fn(async () => ({ workspaceId: "ws", enabledProviderCount: 1 })), + ...overrides, + } as unknown as PluginsHttp; +} + +async function renderManager(http: PluginsHttp = fakeHttp()) { + const store = createPluginsStore(http); + await store.refresh(); + render(PluginManager, { props: { plugins: store, onClose: vi.fn() } }); + return { store, http }; +} + +describe("PluginManager — tables + locked core", () => { + it("renders the loaded table with tier groups and the discovered table", async () => { + await renderManager(); + expect(screen.getByText("Loaded")).toBeInTheDocument(); + expect(screen.getByText("Discovered (not loaded)")).toBeInTheDocument(); + // Tier subheadings (Core / Provider / Standard) — each also appears as a + // row badge, so assert at least one occurrence of each. + expect(screen.getAllByText("Core").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Provider").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Standard").length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Missing (directory gone)")).toBeInTheDocument(); + expect(screen.getByText("gone")).toBeInTheDocument(); + }); + + it("core rows show a lock instead of a checkbox", async () => { + await renderManager(); + // The kernel row has no checkbox (the lock glyph instead). + expect(screen.queryByRole("checkbox", { name: /Unload kernel/i })).not.toBeInTheDocument(); + // Provider + standard rows have checkboxes. + expect(screen.getByRole("checkbox", { name: /Unload opencode/i })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: /Unload heartbeat/i })).toBeChecked(); + }); + + it("disabled-with-reason rows show their reason", async () => { + await renderManager(); + expect(screen.getByText("activation threw")).toBeInTheDocument(); + }); +}); + +describe("PluginManager — cascade-uncheck warning (§8)", () => { + it("unchecking a plugin with loaded dependents warns first (no request yet)", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + // transport-http depends on heartbeat → unchecking heartbeat cascades. + await user.click(screen.getByRole("checkbox", { name: /Unload heartbeat/i })); + expect(http.unload).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog", { name: /Unload heartbeat/i })).toBeInTheDocument(); + expect(screen.getByText(/also unchecks: transport-http/)).toBeInTheDocument(); + }); + + it("accepting the warning issues the forced unload (the cascade is server-side)", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + await user.click(screen.getByRole("checkbox", { name: /Unload heartbeat/i })); + await user.click(screen.getByRole("button", { name: "Unload with force" })); + expect(http.unload).toHaveBeenCalledWith("heartbeat", true); + expect(screen.queryByRole("dialog", { name: /Unload heartbeat/i })).not.toBeInTheDocument(); + }); + + it("cancelling the warning issues nothing", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + await user.click(screen.getByRole("checkbox", { name: /Unload heartbeat/i })); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(http.unload).not.toHaveBeenCalled(); + }); + + it("unchecking WITHOUT dependents unloads directly (no warning)", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + // transport-http has no loaded dependents (it DEPENDS on heartbeat, not + // the reverse) — and it is not a provider, so the last-provider lock + // (which correctly disables `opencode`, the fixture's only provider) + // does not apply. + await user.click(screen.getByRole("checkbox", { name: /Unload transport-http/i })); + expect(http.unload).toHaveBeenCalledWith("transport-http", false); + expect(screen.queryByRole("dialog", { name: /Unload/i })).not.toBeInTheDocument(); + }); + + it("a refused unload offers force with the global-stop copy (§8 in-use force)", async () => { + const user = userEvent.setup(); + const http = fakeHttp({ + unload: vi.fn( + async (): Promise => ({ + ok: false, + error: "the engine refused — the plugin is in use", + reason: "engine-refused", + }), + ), + }); + await renderManager(http); + await user.click(screen.getByRole("checkbox", { name: /Unload transport-http/i })); + // The refusal is surfaced both in the manager's error line and the + // confirm dialog's notice (the dialog appears after the async refusal). + expect( + await screen.findAllByText(/the engine refused/).then((els) => els.length), + ).toBeGreaterThanOrEqual(1); + expect(await screen.findByText(/stops ALL agents globally/i)).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Force unload" })); + expect(http.unload).toHaveBeenLastCalledWith("transport-http", true); + }); +}); + +describe("PluginManager — last-provider lock (§8)", () => { + it("the last loaded provider's checkbox is disabled with a tooltip", async () => { + const single: PluginListResponse = { + loaded: [ + { + id: "opencode", + name: "OpenCode", + version: "2.0.0", + tier: "providers", + trust: "bundled", + locked: false, + dependsOn: [], + }, + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + ], + disabledWithReason: [], + discovered: [], + missing: [], + }; + const store = createPluginsStore(fakeHttp({ list: async () => single })); + await store.refresh(); + render(PluginManager, { props: { plugins: store, onClose: vi.fn() } }); + const checkbox = screen.getByRole("checkbox", { name: /Unload opencode/i }); + expect(checkbox).toBeDisabled(); + // The explanatory copy is wired via the title attribute (the tooltip). + expect(checkbox.getAttribute("title")).toMatch(/last provider/i); + }); +}); + +describe("PluginManager — load with auto-checked prereqs (§8)", () => { + it("checking a discovered plugin loads it and reflects the stateful refetch", async () => { + const user = userEvent.setup(); + // Stateful fake: after `load("fresh")`, the list reports fresh as loaded + // (the store refetches from the server after the op — the checkbox must + // reflect the NEW list, not a local guess). + let freshLoaded = false; + const stateful: PluginsHttp = { + ...fakeHttp(), + list: vi.fn(async () => + freshLoaded + ? { + ...listResponse, + discovered: [], + loaded: [ + ...listResponse.loaded, + { + id: "fresh", + name: "Fresh", + version: "3.0.0", + tier: "standard", + trust: "external", + locked: false, + dependsOn: [], + }, + ], + } + : listResponse, + ), + load: vi.fn(async (id: string) => { + if (id === "fresh") freshLoaded = true; + return { ok: true, response: { ok: true, action: "load", id } }; + }), + } as unknown as PluginsHttp; + const store = createPluginsStore(stateful); + await store.refresh(); + render(PluginManager, { props: { plugins: store, onClose: vi.fn() } }); + await user.click(screen.getByRole("checkbox", { name: /Load fresh/i })); + expect(stateful.load).toHaveBeenCalledWith("fresh"); + expect(screen.getByRole("checkbox", { name: /Unload fresh/i })).toBeChecked(); + }); + + it("the Scan button triggers a manual scan", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + await user.click(screen.getByRole("button", { name: /Scan/ })); + expect(http.scan).toHaveBeenCalledOnce(); + }); + + it("the Reload affordance calls reload for a loaded row", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + await user.click(screen.getByRole("button", { name: /Reload transport-http/i })); + expect(http.reload).toHaveBeenCalledWith("transport-http"); + }); +}); diff --git a/src/features/plugins/ui/PluginRow.svelte b/src/features/plugins/ui/PluginRow.svelte new file mode 100644 index 0000000..457bd29 --- /dev/null +++ b/src/features/plugins/ui/PluginRow.svelte @@ -0,0 +1,116 @@ + + + + onHover(node.id)} + onmouseout={() => onUnhover(node.id)} + onfocus={() => onHover(node.id)} + onblur={() => onUnhover(node.id)} +> + + {#if hasCheckbox} + {#if node.locked} + 🔒 + {:else} + onToggle(node, e.currentTarget.checked)} + /> + {/if} + {/if} + + +
+ {node.name} + {#if node.reason !== null} + {node.reason} + {/if} + {#if node.dependsOn.length > 0} + depends on: {node.dependsOn.join(", ")} + {/if} +
+ + + {tierLabel(node.tier)} + {#if node.trust !== null} + {node.trust} + {/if} + + {node.version} + + {#if node.loaded && onReload !== undefined} + + {/if} + + diff --git a/src/features/plugins/ui/PluginTable.svelte b/src/features/plugins/ui/PluginTable.svelte new file mode 100644 index 0000000..717f839 --- /dev/null +++ b/src/features/plugins/ui/PluginTable.svelte @@ -0,0 +1,85 @@ + + +
+

{title}

+ {#if rows.length === 0} +

Nothing here.

+ {:else} + + + + + + + + + + + {#each groups as group (group.tier)} + + + + + {#each group.rows as node (node.id)} + + {/each} + + {/each} +
+ Load/unload + PluginClassVersion + Actions +
+ {tierLabel(group.tier)} +
+ {/if} +
diff --git a/src/features/plugins/ui/UnloadConfirmModal.svelte b/src/features/plugins/ui/UnloadConfirmModal.svelte new file mode 100644 index 0000000..6ae95cc --- /dev/null +++ b/src/features/plugins/ui/UnloadConfirmModal.svelte @@ -0,0 +1,40 @@ + + + diff --git a/src/features/plugins/ui/WorkspacePluginModal.svelte b/src/features/plugins/ui/WorkspacePluginModal.svelte new file mode 100644 index 0000000..bce885b --- /dev/null +++ b/src/features/plugins/ui/WorkspacePluginModal.svelte @@ -0,0 +1,110 @@ + + + diff --git a/src/features/plugins/ui/WorkspacePluginModal.test.ts b/src/features/plugins/ui/WorkspacePluginModal.test.ts new file mode 100644 index 0000000..32e88d7 --- /dev/null +++ b/src/features/plugins/ui/WorkspacePluginModal.test.ts @@ -0,0 +1,124 @@ +import type { WorkspacePluginsResponse } from "@dispatch/transport-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { PluginsHttp } from "../adapter/http"; +import { createPluginsStore } from "../store.svelte"; +import WorkspacePluginModal from "./WorkspacePluginModal.svelte"; + +const wsResponse: WorkspacePluginsResponse = { + workspaceId: "ws", + plugins: [ + // Core: enabled + locked (the server materializes core to enabled and + // refuses its PUT with a 409 core-locked — plan 06 §6.6). + { pluginId: "kernel", tier: "core", state: "enabled", loaded: true, locked: true }, + // Loaded standard: interactive. + { pluginId: "mcp", tier: "standard", state: "undefined", loaded: true, locked: false }, + // UNLOADED: greyed out + unselectable (§7 strict layering). + { pluginId: "notloaded", tier: "standard", state: "disabled", loaded: false, locked: false }, + ], + enabledProviderCount: 1, +}; + +function fakeHttp(overrides: Partial = {}): PluginsHttp { + return { + list: vi.fn(async () => null), + scan: vi.fn(), + load: vi.fn(), + unload: vi.fn(), + reload: vi.fn(), + workspacePlugins: vi.fn(async () => wsResponse), + setWorkspaceState: vi.fn(async () => ({ + ok: true, + response: { workspaceId: "ws", pluginId: "mcp", state: "enabled" }, + })), + providersStatus: vi.fn(async () => ({ workspaceId: "ws", enabledProviderCount: 1 })), + ...overrides, + } as unknown as PluginsHttp; +} + +async function renderModal(http: PluginsHttp = fakeHttp()) { + const store = createPluginsStore(http); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { workspaceId: "ws", workspaceTitle: "My Workspace", plugins: store, onClose: vi.fn() }, + }); + return { store, http }; +} + +describe("WorkspacePluginModal", () => { + it("renders the workspace's plugin rows", async () => { + await renderModal(); + expect(screen.getByText("Plugins — My Workspace")).toBeInTheDocument(); + expect(screen.getByText("kernel")).toBeInTheDocument(); + expect(screen.getByText("mcp")).toBeInTheDocument(); + }); + + it("checkboxes reflect the workspace state (enabled=checked, undefined=unchecked)", async () => { + await renderModal(); + expect(screen.getByRole("checkbox", { name: /kernel/i })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: /Enable mcp/i })).not.toBeChecked(); + }); + + it("unloaded plugins are greyed out AND unselectable (§7)", async () => { + await renderModal(); + const checkbox = screen.getByRole("checkbox", { name: /notloaded/i }); + expect(checkbox).toBeDisabled(); + // The row is visually greyed (the opacity marker class). + expect(checkbox.closest("tr")?.className).toContain("opacity-50"); + // The tooltip points at the global manager. + expect(checkbox.getAttribute("title")).toMatch(/plugin manager/i); + }); + + it("core rows render locked (checked but unselectable — the server refuses the PUT)", async () => { + await renderModal(); + const checkbox = screen.getByRole("checkbox", { name: /kernel/i }); + expect(checkbox).toBeDisabled(); + expect(checkbox.getAttribute("title")).toMatch(/Core plugins are enabled by default/i); + }); + + it("checking a selectable row writes enabled via the store", async () => { + const user = userEvent.setup(); + const { http } = await renderModal(); + await user.click(screen.getByRole("checkbox", { name: /Enable mcp/i })); + expect(http.setWorkspaceState).toHaveBeenCalledWith("ws", "mcp", "enabled"); + }); + + it("unchecking a checked row writes disabled", async () => { + const user = userEvent.setup(); + const enabled: WorkspacePluginsResponse = { + workspaceId: "ws", + plugins: [ + { pluginId: "mcp", tier: "standard", state: "enabled", loaded: true, locked: false }, + ], + enabledProviderCount: 1, + }; + const fake = fakeHttp({ workspacePlugins: async () => enabled }); + const store = createPluginsStore(fake); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { + workspaceId: "ws", + workspaceTitle: "My Workspace", + plugins: store, + onClose: vi.fn(), + }, + }); + await user.click(screen.getByRole("checkbox", { name: /Disable mcp/i })); + expect(fake.setWorkspaceState).toHaveBeenCalledWith("ws", "mcp", "disabled"); + }); + + it("shows the template hint for the default workspace", async () => { + const store = createPluginsStore(fakeHttp()); + await store.openWorkspace("default"); + render(WorkspacePluginModal, { + props: { + workspaceId: "default", + workspaceTitle: "Default", + plugins: store, + onClose: vi.fn(), + }, + }); + expect(screen.getByText(/other workspaces inherit these states/i)).toBeInTheDocument(); + }); +}); -- cgit v1.2.3