summaryrefslogtreecommitdiffhomepage
path: root/src/features/plugins/ui
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/plugins/ui')
-rw-r--r--src/features/plugins/ui/PluginManager.svelte221
-rw-r--r--src/features/plugins/ui/PluginManager.test.ts284
-rw-r--r--src/features/plugins/ui/PluginRow.svelte120
-rw-r--r--src/features/plugins/ui/PluginTable.svelte85
-rw-r--r--src/features/plugins/ui/ResetLayoutConfirm.svelte30
-rw-r--r--src/features/plugins/ui/UnloadConfirmModal.svelte40
-rw-r--r--src/features/plugins/ui/WorkspacePluginModal.svelte171
-rw-r--r--src/features/plugins/ui/WorkspacePluginModal.test.ts258
8 files changed, 1209 insertions, 0 deletions
diff --git a/src/features/plugins/ui/PluginManager.svelte b/src/features/plugins/ui/PluginManager.svelte
new file mode 100644
index 0000000..fd2a8a7
--- /dev/null
+++ b/src/features/plugins/ui/PluginManager.svelte
@@ -0,0 +1,221 @@
+<script lang="ts">
+ import { onMount } from "svelte";
+ import type { PluginsStore } from "../store.svelte";
+ import type { PluginNode } from "../logic/types";
+ import { computeCascade } from "../logic/cascade";
+ import { autoCheckBackendPrereqs } from "../logic/prereqs";
+ import { dependencyHighlightSets } from "../logic/highlight";
+ import { unloadBlockReason } from "../logic/last-provider";
+ import { splitSections, withFrontendStates } from "../logic/view-model";
+ import PluginTable from "./PluginTable.svelte";
+ import UnloadConfirmModal from "./UnloadConfirmModal.svelte";
+
+ let { plugins, onClose }: { plugins: PluginsStore; onClose: () => void } = $props();
+
+ onMount(() => {
+ void plugins.refresh();
+ });
+
+ // ── Derived sections + hover highlighting (§8) ────────────────────────────
+ // The FE-half states (plan 03) merge into the nodes here — one reactive
+ // derivation over both inputs (the normalized nodes + the plugin host's
+ // post-sync state map).
+ const nodes = $derived(withFrontendStates(plugins.nodes, plugins.frontendStates));
+ const sections = $derived(splitSections(nodes));
+ let hoveredId = $state<string | null>(null);
+ const highlight = $derived(dependencyHighlightSets(hoveredId, nodes));
+
+ function highlightFor(id: string): "dep" | "dependent" | null {
+ if (highlight.deps.has(id)) return "dep";
+ if (highlight.dependents.has(id)) return "dependent";
+ return null;
+ }
+
+ // ── Mutations ─────────────────────────────────────────────────────────────
+ let busyIds = $state<ReadonlySet<string>>(new Set());
+ let scanning = $state(false);
+
+ async function withBusy(id: string, op: () => Promise<unknown>): Promise<void> {
+ busyIds = new Set(busyIds).add(id);
+ try {
+ await op();
+ } finally {
+ const next = new Set(busyIds);
+ next.delete(id);
+ busyIds = next;
+ }
+ }
+
+ // ── Unload confirm state (cascade warn §8 + in-use force §8) ─────────────
+ // The pending unload the confirm modal describes. `cascade` lists the loaded
+ // dependents that will also be unchecked; `notice` carries a refused-attempt
+ // error (in-use / engine refusal) that motivated the force offer.
+ let pendingUnload = $state<{
+ node: PluginNode;
+ cascade: readonly string[];
+ notice: string | null;
+ } | null>(null);
+
+ async function toggle(node: PluginNode, checked: boolean): Promise<void> {
+ if (busyIds.has(node.id)) return;
+ if (checked) {
+ // §8: checking auto-checks the required backend plugins (transitive,
+ // one-directional). Load the missing prereqs first, then the target.
+ const prereqs = autoCheckBackendPrereqs(node.id, plugins.nodes);
+ for (const id of prereqs) {
+ await withBusy(id, () => plugins.load(id));
+ }
+ await withBusy(node.id, () => plugins.load(node.id));
+ return;
+ }
+ // Unchecking: warn when loaded dependents would cascade-uncheck (§8).
+ const cascade = computeCascade(node.id, plugins.nodes);
+ if (cascade.length > 0) {
+ pendingUnload = { node, cascade, notice: null };
+ return;
+ }
+ // No local cascade — attempt the unload; a server refusal (in-use /
+ // engine) offers force with the global-stop warning (§8).
+ const result = await withBusyRes(node.id, () => plugins.unload(node.id, false));
+ if (result !== null && !result.ok) {
+ pendingUnload = { node, cascade: result.dependents ?? [], notice: result.error };
+ }
+ }
+
+ /** Like `withBusy` but returns the op's result (for the unload flow). */
+ async function withBusyRes<T>(id: string, op: () => Promise<T>): Promise<T | null> {
+ busyIds = new Set(busyIds).add(id);
+ try {
+ return await op();
+ } finally {
+ const next = new Set(busyIds);
+ next.delete(id);
+ busyIds = next;
+ }
+ }
+
+ async function confirmUnload(): Promise<void> {
+ const pending = pendingUnload;
+ pendingUnload = null;
+ if (pending === null) return;
+ // Force cascades the dependents server-side (plan 05: reverse-topological
+ // unload) and stops ALL agents globally first — the modal's copy says so.
+ await withBusy(pending.node.id, () => plugins.unload(pending.node.id, true));
+ }
+
+ function cancelUnload(): void {
+ pendingUnload = null;
+ }
+
+ async function reload(node: PluginNode): Promise<void> {
+ await withBusy(node.id, () => plugins.reload(node.id));
+ }
+
+ async function scan(): Promise<void> {
+ scanning = true;
+ await plugins.scan();
+ scanning = false;
+ }
+
+ const scanSummary = $derived.by(() => {
+ const last = plugins.lastScan;
+ if (last === null || !last.ok) return null;
+ const r = last.response;
+ const parts: string[] = [`${r.newPlugins.length} new`];
+ if (r.invalid.length > 0) parts.push(`${r.invalid.length} invalid`);
+ if (r.incompatible.length > 0) parts.push(`${r.incompatible.length} incompatible`);
+ return `Scan of ${r.root}: ${parts.join(", ")}`;
+ });
+</script>
+
+<div class="modal modal-open" role="dialog" aria-label="Plugin manager">
+ <div class="modal-box max-w-3xl">
+ <div class="flex items-center justify-between">
+ <h3 class="text-lg font-bold">Plugin manager</h3>
+ <button
+ type="button"
+ class="btn btn-sm"
+ disabled={scanning}
+ onclick={scan}
+ title="Rescan the plugin directory (new plugins stay unloaded — the consent gate)"
+ >
+ {#if scanning}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Scan
+ {/if}
+ </button>
+ </div>
+
+ {#if plugins.error}
+ <p class="mt-2 text-xs text-error" role="alert">{plugins.error}</p>
+ {/if}
+ {#if scanSummary}
+ <p class="mt-2 text-xs opacity-70" role="status">{scanSummary}</p>
+ {/if}
+
+ <div class="mt-3 flex flex-col gap-4">
+ <PluginTable
+ title="Loaded"
+ rows={sections.loaded}
+ blockReasonFor={(id) => unloadBlockReason(id, plugins.nodes)}
+ {highlightFor}
+ {busyIds}
+ onToggle={toggle}
+ onReload={reload}
+ onHover={(id) => (hoveredId = id)}
+ onUnhover={(id) => (hoveredId = hoveredId === id ? null : hoveredId)}
+ />
+
+ <PluginTable
+ title="Discovered (not loaded)"
+ rows={sections.discovered}
+ blockReasonFor={() => null}
+ {highlightFor}
+ {busyIds}
+ onToggle={toggle}
+ onHover={(id) => (hoveredId = id)}
+ onUnhover={(id) => (hoveredId = hoveredId === id ? null : hoveredId)}
+ />
+
+ {#if sections.missing.length > 0}
+ <section class="flex flex-col gap-1">
+ <h4 class="text-xs font-semibold uppercase opacity-60">Missing (directory gone)</h4>
+ <ul class="flex flex-col gap-1">
+ {#each sections.missing as node (node.id)}
+ <li class="rounded bg-base-200/40 px-2 py-1 text-xs opacity-60">
+ <span class="font-mono">{node.id}</span> — {node.reason}
+ </li>
+ {/each}
+ </ul>
+ </section>
+ {/if}
+ </div>
+
+ <div class="modal-action">
+ <p class="mr-auto self-center text-xs opacity-50">
+ Hover a row to highlight its dependencies and dependents.
+ </p>
+ <button type="button" class="btn btn-sm" onclick={onClose}>Close</button>
+ </div>
+ </div>
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
+ <div class="modal-backdrop" onclick={onClose}></div>
+</div>
+
+{#if pendingUnload !== null}
+ <UnloadConfirmModal
+ headline={pendingUnload.node.loaded ? `Unload ${pendingUnload.node.id}?` : `Force-unload ${pendingUnload.node.id}?`}
+ lines={[
+ ...(pendingUnload.cascade.length > 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> = {}): 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<PluginOpResult> => ({
+ 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..73e0b18
--- /dev/null
+++ b/src/features/plugins/ui/PluginRow.svelte
@@ -0,0 +1,120 @@
+<script lang="ts">
+ import type { PluginNode } from "../logic/types";
+ import { tierLabel } from "../logic/types";
+
+ let {
+ node,
+ highlight,
+ blockReason = null,
+ busy = false,
+ onToggle,
+ onReload,
+ onHover,
+ onUnhover,
+ }: {
+ node: PluginNode;
+ /** Hover-highlight state for this row: a direct dep, a dependent, or none. */
+ highlight: "dep" | "dependent" | null;
+ /**
+ * Why the unload control is disabled (core-locked / last-provider), or
+ * null when interactive. Missing rows are always disabled (no plugin).
+ */
+ blockReason?: string | null;
+ /** Whether a mutation for this row is in flight (spinner in the controls). */
+ busy?: boolean;
+ onToggle: (node: PluginNode, checked: boolean) => void;
+ onReload?: (node: PluginNode) => void;
+ onHover: (id: string) => void;
+ onUnhover: (id: string) => void;
+ } = $props();
+
+ // Missing rows have no checkbox at all — there is no plugin to load.
+ const hasCheckbox = $derived(node.section !== "missing");
+ // Core rows are LOCKED (no load/unload control) per §3/§5.
+ const interactive = $derived(blockReason === null && !busy);
+ const rowClass = $derived(
+ [
+ node.section === "missing" || node.locked || (!node.loaded && node.section === "discovered")
+ ? "opacity-50"
+ : "",
+ highlight === "dep"
+ ? "outline outline-1 outline-info bg-info/10"
+ : highlight === "dependent"
+ ? "outline outline-1 outline-warning bg-warning/10"
+ : "",
+ ]
+ .filter(Boolean)
+ .join(" "),
+ );
+</script>
+
+<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
+<tr
+ class={rowClass}
+ onmouseover={() => onHover(node.id)}
+ onmouseout={() => onUnhover(node.id)}
+ onfocus={() => onHover(node.id)}
+ onblur={() => onUnhover(node.id)}
+>
+ <td class="w-10">
+ {#if hasCheckbox}
+ {#if node.locked}
+ <span
+ class="inline-block w-4 text-center opacity-40"
+ title={blockReason ?? "Core plugins cannot be unloaded"}
+ aria-label="Locked (core)">🔒</span
+ >
+ {:else}
+ <input
+ type="checkbox"
+ class="checkbox checkbox-sm"
+ checked={node.loaded}
+ disabled={!interactive}
+ title={blockReason ?? undefined}
+ aria-label={`${node.loaded ? "Unload" : "Load"} ${node.id}`}
+ onchange={(e) => onToggle(node, e.currentTarget.checked)}
+ />
+ {/if}
+ {/if}
+ </td>
+ <td>
+ <div class="flex flex-col">
+ <span class="font-medium">{node.name}</span>
+ {#if node.reason !== null}
+ <span class="text-xs text-error">{node.reason}</span>
+ {/if}
+ {#if node.frontendIssue !== undefined}
+ <!-- plan 03: the FE half's state — "incompatible frontend plugin" or the activation error -->
+ <span class="text-xs text-error">{node.frontendIssue}</span>
+ {/if}
+ {#if node.dependsOn.length > 0}
+ <span class="text-xs opacity-60">depends on: {node.dependsOn.join(", ")}</span>
+ {/if}
+ </div>
+ </td>
+ <td class="text-xs">
+ <span class="badge badge-ghost badge-sm">{tierLabel(node.tier)}</span>
+ {#if node.trust !== null}
+ <span class="badge badge-ghost badge-sm">{node.trust}</span>
+ {/if}
+ </td>
+ <td class="text-xs opacity-60">{node.version}</td>
+ <td class="text-right">
+ {#if node.loaded && onReload !== undefined}
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={!interactive}
+ title="Reload this plugin's code (picks up updates)"
+ aria-label={`Reload ${node.id}`}
+ onclick={() => onReload?.(node)}
+ >
+ {#if busy}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ ↻
+ {/if}
+ </button>
+ {/if}
+ </td>
+</tr>
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 @@
+<script lang="ts">
+ import type { PluginNode } from "../logic/types";
+ import { tierLabel } from "../logic/types";
+ import { groupByTier } from "../logic/view-model";
+ import PluginRow from "./PluginRow.svelte";
+
+ let {
+ title,
+ rows,
+ blockReasonFor,
+ highlightFor,
+ busyIds,
+ onToggle,
+ onReload,
+ onHover,
+ onUnhover,
+ }: {
+ title: string;
+ rows: readonly PluginNode[];
+ /**
+ * Why a row's control is disabled (core-locked / last-provider), or null
+ * when interactive. Injected by the manager (it owns the graph context).
+ */
+ blockReasonFor: (id: string) => string | null;
+ /**
+ * The §8 hover-highlight state for one row: a highlighted dependency, a
+ * highlighted dependent, or none. Injected by the manager (it owns the
+ * hovered id + the graph).
+ */
+ highlightFor: (id: string) => "dep" | "dependent" | null;
+ /** Ids with an in-flight mutation (spinner in their controls). */
+ busyIds: ReadonlySet<string>;
+ onToggle: (node: PluginNode, checked: boolean) => void;
+ onReload?: (node: PluginNode) => void;
+ onHover: (id: string) => void;
+ onUnhover: (id: string) => void;
+ } = $props();
+
+ // Tier-grouped rows (Core → Providers → Standard) per the plan-07 decision.
+ const groups = $derived(groupByTier(rows));
+</script>
+
+<section class="flex flex-col gap-1">
+ <h4 class="text-xs font-semibold uppercase opacity-60">{title}</h4>
+ {#if rows.length === 0}
+ <p class="text-xs opacity-50">Nothing here.</p>
+ {:else}
+ <table class="table table-sm">
+ <thead>
+ <tr>
+ <th class="w-10">
+ <span class="sr-only">Load/unload</span>
+ </th>
+ <th>Plugin</th>
+ <th>Class</th>
+ <th>Version</th>
+ <th class="text-right">
+ <span class="sr-only">Actions</span>
+ </th>
+ </tr>
+ </thead>
+ {#each groups as group (group.tier)}
+ <tbody>
+ <tr>
+ <td colspan="5" class="bg-base-200/40 text-xs font-semibold uppercase opacity-60">
+ {tierLabel(group.tier)}
+ </td>
+ </tr>
+ {#each group.rows as node (node.id)}
+ <PluginRow
+ {node}
+ highlight={highlightFor(node.id)}
+ blockReason={blockReasonFor(node.id)}
+ busy={busyIds.has(node.id)}
+ {onToggle}
+ {...(onReload ? { onReload } : {})}
+ {onHover}
+ {onUnhover}
+ />
+ {/each}
+ </tbody>
+ {/each}
+ </table>
+ {/if}
+</section>
diff --git a/src/features/plugins/ui/ResetLayoutConfirm.svelte b/src/features/plugins/ui/ResetLayoutConfirm.svelte
new file mode 100644
index 0000000..1f26601
--- /dev/null
+++ b/src/features/plugins/ui/ResetLayoutConfirm.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ /**
+ * The "Reset layout to default?" confirm (plan 04 §4.4) — the dialog copy
+ * names the consequence exactly: this workspace's layout returns to the
+ * default; plugin and dropdown customizations are lost (there is NO
+ * undo — the no-revert semantics make the reset the recovery path).
+ */
+ let {
+ onConfirm,
+ onCancel,
+ }: {
+ onConfirm: () => void;
+ onCancel: () => void;
+ } = $props();
+</script>
+
+<div class="modal modal-open" role="dialog" aria-label="Reset layout to default?">
+ <div class="modal-box max-w-md">
+ <h3 class="text-lg font-bold text-warning">Reset layout to default?</h3>
+ <p class="py-3 text-sm">
+ This workspace's layout returns to the default; plugin and dropdown customizations are lost.
+ </p>
+ <div class="modal-action">
+ <button type="button" class="btn btn-ghost btn-sm" onclick={onCancel}>Cancel</button>
+ <button type="button" class="btn btn-warning btn-sm" onclick={onConfirm}>Reset</button>
+ </div>
+ </div>
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
+ <div class="modal-backdrop" onclick={onCancel}></div>
+</div>
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 @@
+<script lang="ts">
+ let {
+ headline,
+ lines,
+ confirmLabel,
+ onCancel,
+ onConfirm,
+ }: {
+ /** The warning headline (e.g. "Unload heartbeat?"). */
+ headline: string;
+ /** The warning body lines: what else unchecks + the force blast radius. */
+ lines: readonly string[];
+ confirmLabel: string;
+ onCancel: () => void;
+ onConfirm: () => void;
+ } = $props();
+</script>
+
+<div class="modal modal-open" role="dialog" aria-label={headline}>
+ <div class="modal-box max-w-md">
+ <h3 class="text-lg font-bold text-warning">{headline}</h3>
+ <div class="flex flex-col gap-1 py-3">
+ {#each lines as line (line)}
+ <p class="text-sm">{line}</p>
+ {/each}
+ <p class="mt-1 text-xs text-error">
+ Force stops ALL agents globally — every active turn in every workspace — not just agents
+ using this plugin.
+ </p>
+ </div>
+ <div class="modal-action">
+ <button type="button" class="btn btn-ghost btn-sm" onclick={onCancel}>Cancel</button>
+ <button type="button" class="btn btn-warning btn-sm" onclick={onConfirm}>
+ {confirmLabel}
+ </button>
+ </div>
+ </div>
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
+ <div class="modal-backdrop" onclick={onCancel}></div>
+</div>
diff --git a/src/features/plugins/ui/WorkspacePluginModal.svelte b/src/features/plugins/ui/WorkspacePluginModal.svelte
new file mode 100644
index 0000000..0ba7cd6
--- /dev/null
+++ b/src/features/plugins/ui/WorkspacePluginModal.svelte
@@ -0,0 +1,171 @@
+<script lang="ts">
+ import { onMount } from "svelte";
+ import type { PluginsStore } from "../store.svelte";
+ import { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "../logic/workspace-row";
+ import type { WorkspaceLayoutPort } from "../logic/types";
+ import ResetLayoutConfirm from "./ResetLayoutConfirm.svelte";
+
+ let {
+ workspaceId,
+ workspaceTitle,
+ plugins,
+ layout,
+ onClose,
+ }: {
+ workspaceId: string;
+ /** The workspace's display title (modal heading). */
+ workspaceTitle: string;
+ plugins: PluginsStore;
+ /**
+ * The layout port (plan 04 §4.4) — gates + powers the "Reset layout to
+ * default" affordance. Absent → the section doesn't render (tests).
+ */
+ layout?: WorkspaceLayoutPort;
+ onClose: () => void;
+ } = $props();
+
+ onMount(() => {
+ // Load only when the view isn't already showing THIS workspace (reopening
+ // the same modal never flashes; a different id reloads it).
+ if (plugins.workspaceView?.workspaceId !== workspaceId) {
+ void plugins.openWorkspace(workspaceId);
+ }
+ });
+
+ // The default workspace is the template (§7): its states are what other
+ // workspaces inherit when they first open.
+ const isTemplate = $derived(workspaceId === "default");
+
+ async function toggle(pluginId: string, checked: boolean): Promise<void> {
+ await plugins.setWorkspaceState(workspaceId, pluginId, checked ? "enabled" : "disabled");
+ // The per-workspace activation rule (layout): toggling a plugin may have
+ // changed which layout plugin serves THIS workspace — re-probe so the
+ // newly-enabled provider's tree (or the no-provider degrade) renders
+ // without a reload. The store ignores non-active workspaces.
+ layout?.refresh(workspaceId);
+ }
+
+ // ── The reset affordance (plan 04 §4.4) ─────────────────────────────────
+ let resetOpen = $state(false);
+ let resetting = $state(false);
+ let resetError = $state<string | null>(null);
+
+ async function confirmReset(): Promise<void> {
+ if (layout === undefined || resetting) return;
+ resetting = true;
+ resetError = null;
+ const failure = await layout.reset(workspaceId);
+ resetting = false;
+ resetOpen = false;
+ if (failure !== null) resetError = failure;
+ }
+</script>
+
+<div class="modal modal-open" role="dialog" aria-label={`Plugins for ${workspaceTitle}`}>
+ <div class="modal-box max-w-2xl">
+ <h3 class="text-lg font-bold">Plugins — {workspaceTitle}</h3>
+ <p class="mt-1 text-xs opacity-60">
+ What THIS workspace uses. Unloaded plugins are greyed out — only the global plugin manager
+ loads code.
+ </p>
+ {#if isTemplate}
+ <p class="mt-1 text-xs text-info">
+ This is the default workspace — other workspaces inherit these states when they first open.
+ </p>
+ {/if}
+
+ {#if plugins.error}
+ <p class="mt-2 text-xs text-error" role="alert">{plugins.error}</p>
+ {/if}
+
+ {#if plugins.workspaceView === null}
+ <div class="flex h-24 items-center justify-center">
+ <span class="loading loading-spinner loading-sm opacity-60"></span>
+ </div>
+ {:else}
+ <table class="table table-sm mt-2">
+ <thead>
+ <tr>
+ <th class="w-10">
+ <span class="sr-only">Enabled</span>
+ </th>
+ <th>Plugin</th>
+ <th>State</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each plugins.workspaceView.rows as row (row.pluginId)}
+ <tr class={row.loaded ? "" : "opacity-50"}>
+ <td>
+ {#if rowSelectable(row)}
+ <input
+ type="checkbox"
+ class="checkbox checkbox-sm"
+ checked={rowChecked(row)}
+ aria-label={`${rowChecked(row) ? "Disable" : "Enable"} ${row.pluginId} in this workspace`}
+ onchange={(e) => toggle(row.pluginId, e.currentTarget.checked)}
+ />
+ {:else}
+ <input
+ type="checkbox"
+ class="checkbox checkbox-sm"
+ checked={rowChecked(row)}
+ disabled
+ aria-label={`${row.pluginId} (unavailable in this workspace)`}
+ title={rowBlockReason(row) ?? undefined}
+ />
+ {/if}
+ </td>
+ <td>
+ <span class="font-medium">{row.pluginId}</span>
+ <span class="badge badge-ghost badge-sm ml-2 text-xs">{row.tier}</span>
+ </td>
+ <td class="text-xs opacity-70">
+ {stateLabel(row.state)}
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ {/if}
+
+ {#if layout}
+ <div class="mt-3 border-t border-base-300 pt-3">
+ <div class="flex items-center justify-between gap-3">
+ <div>
+ <p class="text-sm font-medium">Layout</p>
+ {#if layout.providerActive}
+ <p class="text-xs opacity-60">Managed by the active layout plugin.</p>
+ {:else}
+ <p class="text-xs opacity-60">No layout plugin enabled — no layout to reset.</p>
+ {/if}
+ </div>
+ <button
+ type="button"
+ class="btn btn-warning btn-sm"
+ disabled={!layout.providerActive || resetting}
+ title={layout.providerActive
+ ? "Restore this workspace's default layout"
+ : "No layout plugin enabled"}
+ onclick={() => (resetOpen = true)}
+ >
+ Reset layout to default
+ </button>
+ </div>
+ {#if resetError}
+ <p class="mt-1 text-xs text-error" role="alert">Resetting the layout failed: {resetError}</p>
+ {/if}
+ </div>
+ {/if}
+
+ <div class="modal-action">
+ <button type="button" class="btn btn-sm" onclick={onClose}>Close</button>
+ </div>
+ </div>
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
+ <div class="modal-backdrop" onclick={onClose}></div>
+</div>
+
+{#if resetOpen && layout}
+ <ResetLayoutConfirm onConfirm={() => void confirmReset()} onCancel={() => (resetOpen = false)} />
+{/if}
diff --git a/src/features/plugins/ui/WorkspacePluginModal.test.ts b/src/features/plugins/ui/WorkspacePluginModal.test.ts
new file mode 100644
index 0000000..4c75ab3
--- /dev/null
+++ b/src/features/plugins/ui/WorkspacePluginModal.test.ts
@@ -0,0 +1,258 @@
+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 type { WorkspaceLayoutPort } from "../logic/types";
+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> = {}): 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 };
+}
+
+/** A layout port (plan 04 §4.4) scripted per test. */
+function fakeLayoutPort(
+ providerActive = true,
+ reset: WorkspaceLayoutPort["reset"] = vi.fn(async () => null),
+ refresh: WorkspaceLayoutPort["refresh"] = vi.fn(),
+): WorkspaceLayoutPort {
+ return { providerActive, reset, refresh };
+}
+
+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("a successful toggle calls the layout port's refresh (the per-workspace activation may change the served tree)", async () => {
+ const user = userEvent.setup();
+ const refresh = vi.fn();
+ const store = createPluginsStore(fakeHttp());
+ await store.openWorkspace("ws");
+ render(WorkspacePluginModal, {
+ props: {
+ workspaceId: "ws",
+ workspaceTitle: "My Workspace",
+ plugins: store,
+ layout: fakeLayoutPort(
+ true,
+ vi.fn(async () => null),
+ refresh,
+ ),
+ onClose: vi.fn(),
+ },
+ });
+ await user.click(screen.getByRole("checkbox", { name: /Enable mcp/i }));
+ expect(refresh).toHaveBeenCalledWith("ws");
+ });
+
+ it("a REFUSED toggle (state write fails) still calls refresh — the probe is the idempotent catch-up", async () => {
+ const user = userEvent.setup();
+ const refresh = vi.fn();
+ const fake = fakeHttp({
+ setWorkspaceState: async () => ({
+ ok: false,
+ error: "Plugin is not loaded — load it globally first",
+ }),
+ });
+ const store = createPluginsStore(fake);
+ await store.openWorkspace("ws");
+ render(WorkspacePluginModal, {
+ props: {
+ workspaceId: "ws",
+ workspaceTitle: "My Workspace",
+ plugins: store,
+ layout: fakeLayoutPort(
+ true,
+ vi.fn(async () => null),
+ refresh,
+ ),
+ onClose: vi.fn(),
+ },
+ });
+ await user.click(screen.getByRole("checkbox", { name: /Enable mcp/i }));
+ // The attempted state change re-probes either way (a refused write changed
+ // nothing, so the probe re-syncs the same tree — harmless and consistent).
+ expect(refresh).toHaveBeenCalledWith("ws");
+ });
+
+ 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();
+ });
+});
+
+describe("WorkspacePluginModal — the reset-layout affordance (plan 04 §4.4)", () => {
+ async function renderWithLayout(layout?: ReturnType<typeof fakeLayoutPort>) {
+ const store = createPluginsStore(fakeHttp());
+ await store.openWorkspace("ws");
+ render(WorkspacePluginModal, {
+ props: {
+ workspaceId: "ws",
+ workspaceTitle: "My Workspace",
+ plugins: store,
+ ...(layout ? { layout } : {}),
+ onClose: vi.fn(),
+ },
+ });
+ return layout;
+ }
+
+ it("renders an ENABLED reset button when a layout provider is active", async () => {
+ await renderWithLayout(fakeLayoutPort(true));
+ const button = screen.getByRole("button", { name: /reset layout to default/i });
+ expect(button).toBeEnabled();
+ });
+
+ it("the button is DISABLED with the 'no layout plugin enabled' reason when no provider is active", async () => {
+ await renderWithLayout(fakeLayoutPort(false));
+ const button = screen.getByRole("button", { name: /reset layout to default/i });
+ expect(button).toBeDisabled();
+ expect(button.getAttribute("title")).toMatch(/no layout plugin enabled/i);
+ expect(screen.getByText(/no layout plugin enabled/i)).toBeInTheDocument();
+ });
+
+ it("no layout port → the layout section doesn't render at all (tests / no wiring)", async () => {
+ await renderWithLayout(undefined);
+ expect(screen.queryByRole("button", { name: /reset layout to default/i })).toBeNull();
+ });
+
+ it("confirming the dialog resets the layout (and the dialog copy names the consequence)", async () => {
+ const user = userEvent.setup();
+ const reset = vi.fn(async () => null);
+ await renderWithLayout(fakeLayoutPort(true, reset));
+
+ await user.click(screen.getByRole("button", { name: /reset layout to default/i }));
+ // The confirm dialog names the consequence exactly (§4.4 copy).
+ expect(
+ screen.getByText(
+ /layout returns to the default; plugin and dropdown customizations are lost/i,
+ ),
+ ).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: /^reset$/i }));
+ expect(reset).toHaveBeenCalledWith("ws");
+ });
+
+ it("cancelling the dialog never resets", async () => {
+ const user = userEvent.setup();
+ const reset = vi.fn(async () => null);
+ await renderWithLayout(fakeLayoutPort(true, reset));
+
+ await user.click(screen.getByRole("button", { name: /reset layout to default/i }));
+ await user.click(screen.getByRole("button", { name: /cancel/i }));
+ expect(reset).not.toHaveBeenCalled();
+ });
+
+ it("a failed reset surfaces the reason as an alert", async () => {
+ const user = userEvent.setup();
+ const reset = vi.fn(async () => "no layout plugin enabled");
+ await renderWithLayout(fakeLayoutPort(true, reset));
+
+ await user.click(screen.getByRole("button", { name: /reset layout to default/i }));
+ await user.click(screen.getByRole("button", { name: /^reset$/i }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(/resetting the layout failed/i);
+ });
+});