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 --- .dispatch/transport-contract.reference.md | 19 ++ .dispatch/ui-contract.reference.md | 7 + backend-handoff.md | 84 ++++++ src/App.svelte | 1 + src/adapters/ws/index.ts | 9 + src/adapters/ws/logic.test.ts | 33 +++ src/adapters/ws/logic.ts | 18 ++ src/app/App.svelte | 1 + src/app/App.test.ts | 4 +- src/app/store.svelte.ts | 159 ++++++++++-- src/app/store.test.ts | 10 +- src/core/wire/conformance.ts | 4 + src/features/chat/ui/ModelSelector.svelte | 17 ++ src/features/plugins/adapter/http.test.ts | 177 +++++++++++++ src/features/plugins/adapter/http.ts | 230 +++++++++++++++++ src/features/plugins/index.ts | 20 ++ src/features/plugins/logic/cascade.test.ts | 66 +++++ src/features/plugins/logic/cascade.ts | 42 +++ src/features/plugins/logic/highlight.test.ts | 60 +++++ src/features/plugins/logic/highlight.ts | 37 +++ src/features/plugins/logic/last-provider.test.ts | 70 +++++ src/features/plugins/logic/last-provider.ts | 31 +++ src/features/plugins/logic/prereqs.test.ts | 58 +++++ src/features/plugins/logic/prereqs.ts | 45 ++++ src/features/plugins/logic/test-fixtures.ts | 30 +++ src/features/plugins/logic/types.ts | 44 ++++ src/features/plugins/logic/view-model.test.ts | 129 ++++++++++ src/features/plugins/logic/view-model.ts | 111 ++++++++ src/features/plugins/logic/workspace-row.test.ts | 67 +++++ src/features/plugins/logic/workspace-row.ts | 54 ++++ src/features/plugins/store.svelte.ts | 198 ++++++++++++++ src/features/plugins/store.test.ts | 183 +++++++++++++ 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 +++++++++ src/features/workspaces/ui/WorkspaceCard.svelte | 32 +++ src/features/workspaces/ui/WorkspacesHome.svelte | 50 +++- 41 files changed, 3037 insertions(+), 39 deletions(-) create mode 100644 src/features/plugins/adapter/http.test.ts create mode 100644 src/features/plugins/adapter/http.ts create mode 100644 src/features/plugins/index.ts create mode 100644 src/features/plugins/logic/cascade.test.ts create mode 100644 src/features/plugins/logic/cascade.ts create mode 100644 src/features/plugins/logic/highlight.test.ts create mode 100644 src/features/plugins/logic/highlight.ts create mode 100644 src/features/plugins/logic/last-provider.test.ts create mode 100644 src/features/plugins/logic/last-provider.ts create mode 100644 src/features/plugins/logic/prereqs.test.ts create mode 100644 src/features/plugins/logic/prereqs.ts create mode 100644 src/features/plugins/logic/test-fixtures.ts create mode 100644 src/features/plugins/logic/types.ts create mode 100644 src/features/plugins/logic/view-model.test.ts create mode 100644 src/features/plugins/logic/view-model.ts create mode 100644 src/features/plugins/logic/workspace-row.test.ts create mode 100644 src/features/plugins/logic/workspace-row.ts create mode 100644 src/features/plugins/store.svelte.ts create mode 100644 src/features/plugins/store.test.ts 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 diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md index 980d38b..f220327 100644 --- a/.dispatch/transport-contract.reference.md +++ b/.dispatch/transport-contract.reference.md @@ -8,6 +8,25 @@ > **Orchestrator:** SNAPSHOT of `transport-contract@0.24.0` (MCP status + computers + provider concurrency + cancel queued message). Regenerate whenever > it changes. > +> **2026-09-03 delta (plugin system — plans 05/06, NO version bump verified against the worktree):** the +> plugin-manager + per-workspace enablement contract ships. NEW WS messages: `WorkspaceSetMessage` +> (`{ type: "workspace.set"; workspaceId }` — client→server; the server records the connection's workspace +> and re-pushes the workspace-filtered surface catalog on receipt + on every broadcast) and +> `ExtensionsChangedMessage` (`{ type: "extensions-changed"; action: "load"|"unload"|"reload"|"scan"|"forget"; +> pluginId? }` — server→client broadcast after every plugin-manager mutation; clients refetch `GET /plugins` +> while the server re-pushes the catalog). NEW HTTP routes: `GET /plugins` (`PluginListResponse` — +> `loaded`/`disabledWithReason`/`discovered`/`missing` rows), `POST /plugins/scan` (`PluginScanResponse`), +> `POST /plugins/:id/load|unload|reload|forget` (`PluginOpResponse` = `PluginOpSuccess | PluginOpBlocked`, +> 404/409 with `PluginBlockReason`: `core-locked | last-provider | cascade-needs-force | not-loaded | +> not-found | already-loaded | engine-refused`), and plan 06 §6.6's `GET /workspaces/:id/plugins` +> (`WorkspacePluginsResponse` — read-through materialization), `PUT /workspaces/:id/plugins/:pluginId` +> (`{ state: "enabled"|"disabled"|"unset" }` → 409 `core-locked|unloaded`), and +> `GET /workspaces/:id/plugins/providers-status` (`{ workspaceId, enabledProviderCount }` — the §6.5 +> zero-providers guardrail signal). `GET /models?workspaceId=` is now workspace-aware (no-param = global). +> NOTE: the body below does NOT yet contain these sections — read the types from +> `node_modules/@dispatch/transport-contract` (or the backend worktree) until this mirror is fully +> regenerated. The FE (plan 07) consumes them via `features/plugins`. +> > **2026-08-20 delta (Named Agents — ADDITIVE, no version bump):** the `/named-agents` HTTP surface + > conversation snapshot wiring ships. New types: `NamedAgentsResponse` (`{ namedAgents: NamedAgent[] }`, > `GET /named-agents`), `NamedAgentResponse` (`{ namedAgent: NamedAgent }`, `POST`/`PUT`), and diff --git a/.dispatch/ui-contract.reference.md b/.dispatch/ui-contract.reference.md index d751af8..15bb785 100644 --- a/.dispatch/ui-contract.reference.md +++ b/.dispatch/ui-contract.reference.md @@ -8,6 +8,13 @@ > **Orchestrator:** this is a SNAPSHOT of `ui-contract@0.2.0` — regenerate it whenever > `ui-contract` changes. > +> **2026-09-03 delta (plugin system — plan 06):** `SurfaceCatalogEntry` gains an OPTIONAL +> `pluginId?: string` — the id of the plugin contributing the surface (plan 06's +> surface→plugin attribution for the per-workspace catalog filter). Absent on older +> backends / host-contributed surfaces — treat as "no plugin" (never filtered). The +> FE does not branch on it yet (the catalog is server-filtered via `workspace.set` +> + `extensions-changed` re-pushes); it is available for future attribution UI. +> > **2026-06-12 delta (CR-2/CR-4 handoff — package bumped `0.1.0` → `0.2.0`):** adds the optional > `scope?: "global" | "conversation"` to `SurfaceCatalogEntry` so a client can skip re-subscribing > GLOBAL surfaces on a conversation switch. ABSENT means assume conversation-scoped (the diff --git a/backend-handoff.md b/backend-handoff.md index 1c56346..a67fac9 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -6,6 +6,90 @@ > contract changes are REPORTED UP here, and on the new version the FE re-pins the `file:` dep, > re-mirrors `.dispatch/*.reference.md`, and updates consumers. NEVER edit the backend repo. +## Milestone: Plugin Manager UI — FE scope (plan 07, feature/plugin-system) + +**Slice status:** COMPLETE + committed (both repos on the `plugin-system` worktree; FE consumes the +plan 05/06 backend contract via the `file:` deps). + +### What the FE built +- **New feature module** `src/features/plugins/`: + - `logic/types.ts` + `logic/view-model.ts` — the `PluginNode` normalization of + `GET /plugins`' four row kinds (loaded / disabledWithReason / discovered / missing) into one + shape for the tables + graph logic; tier grouping (Core → Providers → Standard). + - `logic/cascade.ts` — `computeCascade` (client mirror of the backend's `computeUnloadCascade`: + transitive dependents over the LOADED subgraph only) for the §8 cascade-uncheck warning. + - `logic/prereqs.ts` — `autoCheckBackendPrereqs` (transitive, one-directional) for the §8 + auto-check; the manager issues the missing prereq loads before the target. + - `logic/highlight.ts` — `dependencyHighlightSets` (one-hop both directions, across BOTH tables). + - `logic/last-provider.ts` — `isLastProvider` + `unloadBlockReason` (client mirrors of the §8 locks). + - `logic/workspace-row.ts` — modal checkbox semantics (`rowChecked`/`rowSelectable`/ + `rowBlockReason`/`stateLabel`) over the plan-06 `WorkspacePluginRow`s. + - `adapter/http.ts` — typed calls to `GET /plugins`, `POST /plugins/scan`, + `POST /plugins/:id/load|unload({force})|reload`, `GET /workspaces/:id/plugins`, + `PUT /workspaces/:id/plugins/:pluginId`, `GET /workspaces/:id/plugins/providers-status`; + blocked responses surface `reason` + `dependents`. + - `store.svelte.ts` — `PluginsStore` (list/nodes, scan/load/unload/reload, per-workspace view + with stale-clear + server-authoritative reconcile on 409, `onExtensionsChanged` hook). + - `ui/` — `PluginManager` (loaded/discovered tables + missing strip, Scan, Reload, cascade + confirm, force confirm with the "stops ALL agents globally" copy, hover highlighting), + `PluginTable`/`PluginRow` (locked core rows: no checkbox, 🔒 + tooltip), `WorkspacePluginModal` + (per-workspace enable/disable; unloaded = greyed + unselectable), `UnloadConfirmModal`. +- **Composition root** (`src/app/store.svelte.ts` + `src/App.svelte`): + - the app store owns the `PluginsStore` instance + exposes `store.plugins` / + `store.enabledProviderCount`; + - sends `workspace.set` on boot (queued), on every `setActiveWorkspace`, and on reconnect + (`onReopen` — a new connection is anonymous again); + - `extensions-changed` → `pluginsStore.onExtensionsChanged()` + workspace-scoped model refresh + + provider-status refresh (the catalog itself is re-pushed by the server); + - `GET /models?workspaceId=` on boot / workspace switch / extensions-changed; + - `GET /workspaces/:id/plugins/providers-status` per active workspace → the §7 first-boot + guardrail (`store.enabledProviderCount === 0` → prompt above the model picker). +- **Workspaces page** — global "Plugins" button in the header (→ `PluginManager`); per-card + "Plugins" button (→ `WorkspacePluginModal`). +- **WS adapter** (`src/adapters/ws/`) — parses `extensions-changed` (dispatch → + `onExtensionsChanged` option); `workspace.set` serializes via the existing `WsClientMessage` path. +- **Guardrail** (`features/chat/ui/ModelSelector.svelte`) — new optional `zeroProviders` prop renders + the visible prompt. **Wire/conformance** — `core/wire/conformance.ts` exhaustiveness guards + extended for the two new message types. +- **Tests:** 1356 pass (up from 1256): pure logic (cascade/prereqs/highlight/last-provider/ + view-model/workspace-row), adapter (routing + block mapping + degradation), store (fetch/refresh/ + reconcile/coherence hook), components (locked core, greyed-unloaded, cascade warn + accept, + last-provider disable + tooltip, guardrail), ws parsing. typecheck 0 errors/0 warnings; biome + clean (pre-existing store.test.ts warning + 2 config infos only); vite build succeeds. + +### Pinned contract versions + mirrors +- `@dispatch/transport-contract`: consumed with the plan 05/06 types (`WorkspaceSetMessage`, + `ExtensionsChangedMessage`, `PluginListResponse` family, `WorkspacePluginsResponse` family). + Mirror `.dispatch/transport-contract.reference.md` updated with a **2026-09-03 delta note** (the + body is NOT yet fully regenerated — the delta note tells readers to use the package sources). +- `@dispatch/ui-contract`: consumed with `SurfaceCatalogEntry.pluginId?`. Mirror + `.dispatch/ui-contract.reference.md` updated with a 2026-09-03 delta note. + +### Findings / raises for the backend +1. **§7.3-vs-§6.6 conflict resolved in favor of the shipped contract (FE mirrors the server).** Plan + 07's prose says Core rows are "checked-but-toggleable" in the workspace modal, but the SHIPPED + backend (plan 06 §6.6 + `workspace-plugin-config`) marks core rows `locked: true` and refuses + core PUTs with 409 `core-locked` (the backend's own comment says "the workspace modal renders + core rows as locked"). The modal therefore renders core rows CHECKED + LOCKED (disabled checkbox + + tooltip). If the backend ever relaxes this (plan 06's pure `isPluginUsable` already honors an + explicit core `disabled`), the modal consumes `locked` and will unlock automatically — no FE + change needed. +2. **In-use force attribution (plan-00 Q2):** the FE's force path is triggered by (a) a local + cascade (≥1 loaded dependent → warn + force) or (b) a server refusal (any block reason → the + force confirm with the global-stop copy). There is no FE-readable "in-use" flag on + `PluginOpBlocked`; if the backend later adds a dedicated reason (e.g. `in-use`), the FE flow + already handles it (any refusal → force offer). +3. **No live-probe yet:** this slice touches the wire (`workspace.set` send, `extensions-changed` + parse). The static suites + build are green; a live probe against a running backend + (`scripts/live-probe.ts` + a manual two-client check of the manager) is the remaining human step. + +### Likely next asks (plan 08 / later) +- The `pluginId` on catalog entries is not yet rendered by the FE (no attribution UI requested). +- The renderer registry / dynamic FE loading / tiling is explicitly OUT of this plan (Addendum A) — + a later dedicated agent's job; this plan's manager + modal are placement-agnostic. + +--- + ## Milestone: Named Agents — FE scope (feature/named-agents) **Slice status:** COMPLETE + committed. The FE Named Agents feature is implemented end-to-end against diff --git a/src/App.svelte b/src/App.svelte index 27c9271..151e752 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -73,6 +73,7 @@ onNavigate={navigate} computers={store.computers} hasActive={(id) => store.workspaceHasActiveConversations(id)} + plugins={store.plugins} /> {:else} diff --git a/src/adapters/ws/index.ts b/src/adapters/ws/index.ts index 1309db0..3a6a97d 100644 --- a/src/adapters/ws/index.ts +++ b/src/adapters/ws/index.ts @@ -4,6 +4,7 @@ import type { ConversationCompactedMessage, ConversationOpenMessage, ConversationStatusChangedMessage, + ExtensionsChangedMessage, WsClientMessage, } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; @@ -27,6 +28,12 @@ export interface SurfaceSocketOptions { onConversationStatusChanged?: (msg: ConversationStatusChangedMessage) => void; /** Broadcast when a conversation's history has been compacted (reload needed). */ onConversationCompacted?: (msg: ConversationCompactedMessage) => void; + /** + * Broadcast after every plugin-manager mutation (plan 05 §11): every + * connected frontend refetches its extension list (the server re-pushes the + * workspace-filtered surface catalog itself). + */ + onExtensionsChanged?: (msg: ExtensionsChangedMessage) => void; onReopen?: () => void; socketFactory?: (url: string) => WebSocketLike; } @@ -75,6 +82,8 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa opts.onConversationStatusChanged?.(msg as ConversationStatusChangedMessage); } else if (msg.type === "conversation.compacted") { opts.onConversationCompacted?.(msg as ConversationCompactedMessage); + } else if (msg.type === "extensions-changed") { + opts.onExtensionsChanged?.(msg as ExtensionsChangedMessage); } else { opts.onMessage(msg as SurfaceServerMessage); } diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts index 43cf8ff..c4ec7e3 100644 --- a/src/adapters/ws/logic.test.ts +++ b/src/adapters/ws/logic.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; import { nextBackoffMs, parseServerMessage, serialize } from "./logic"; +describe("serialize — workspace.set (plan 06 §6.4)", () => { + it("serializes a workspace.set client message", () => { + const msg = { type: "workspace.set" as const, workspaceId: "ws-1" }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); +}); + describe("serialize", () => { it("serializes a subscribe message", () => { const msg = { type: "subscribe" as const, surfaceId: "s1" }; @@ -430,3 +437,29 @@ describe("nextBackoffMs", () => { } }); }); + +describe("parseServerMessage — extensions-changed (plan 05)", () => { + it("parses the broadcast with a pluginId", () => { + const result = parseServerMessage( + JSON.stringify({ type: "extensions-changed", action: "unload", pluginId: "mcp" }), + ); + expect(result).toEqual({ type: "extensions-changed", action: "unload", pluginId: "mcp" }); + }); + + it("parses a scan broadcast (no pluginId)", () => { + const result = parseServerMessage( + JSON.stringify({ type: "extensions-changed", action: "scan" }), + ); + expect(result).toEqual({ type: "extensions-changed", action: "scan" }); + }); + + it("rejects an unknown action", () => { + expect( + parseServerMessage(JSON.stringify({ type: "extensions-changed", action: "explode" })), + ).toBeNull(); + }); + + it("rejects a missing action", () => { + expect(parseServerMessage(JSON.stringify({ type: "extensions-changed" }))).toBeNull(); + }); +}); diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts index 72a6338..696655b 100644 --- a/src/adapters/ws/logic.ts +++ b/src/adapters/ws/logic.ts @@ -4,6 +4,7 @@ import type { ConversationCompactedMessage, ConversationOpenMessage, ConversationStatusChangedMessage, + ExtensionsChangedMessage, WsClientMessage, WsServerMessage, } from "@dispatch/transport-contract"; @@ -24,6 +25,7 @@ const VALID_SERVER_TYPES = new Set([ "conversation.open", "conversation.statusChanged", "conversation.compacted", + "extensions-changed", ]); /** Serialize a client message to a JSON string for the wire. */ @@ -162,6 +164,22 @@ export function parseServerMessage(data: string): WsServerMessage | null { }; return msg; } + case "extensions-changed": { + // Plugin-manager broadcast (plan 05): the payload is intentionally + // minimal — clients refetch GET /plugins + the (server re-pushed) + // surface catalog rather than diffing here. + const action = parsed.action; + if (action !== "load" && action !== "unload" && action !== "reload") { + if (action !== "scan" && action !== "forget") return null; + } + const pluginId = parsed.pluginId; + const msg: ExtensionsChangedMessage = { + type: "extensions-changed", + action, + ...(pluginId !== undefined && typeof pluginId === "string" ? { pluginId } : {}), + }; + return msg; + } default: return null; } diff --git a/src/app/App.svelte b/src/app/App.svelte index 9436648..f2d7e9c 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -780,6 +780,7 @@ namedAgents={store.namedAgents} namedAgentSnapshot={store.namedAgentSnapshot} onSelectNamedAgent={handleSelectNamedAgent} + zeroProviders={store.enabledProviderCount === 0} /> + + {/if} 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(); + }); +}); diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte index 6de4109..70cfda2 100644 --- a/src/features/workspaces/ui/WorkspaceCard.svelte +++ b/src/features/workspaces/ui/WorkspaceCard.svelte @@ -1,6 +1,7 @@