summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-03 18:05:29 +0900
committerAdam Malczewski <[email protected]>2026-09-03 18:05:29 +0900
commit3a4922a05930b0dc22740bf5621910803e8f275e (patch)
treed5f831c3d3838cfff346e378f5370a9c58bc2ed3
parent33c7245f18346553f660434bc3ac589cff3e80c2 (diff)
downloaddispatch-web-3a4922a05930b0dc22740bf5621910803e8f275e.tar.gz
dispatch-web-3a4922a05930b0dc22740bf5621910803e8f275e.zip
feat(plugin-system): plan 07 frontend plugin manager ui
-rw-r--r--.dispatch/transport-contract.reference.md19
-rw-r--r--.dispatch/ui-contract.reference.md7
-rw-r--r--backend-handoff.md84
-rw-r--r--src/App.svelte1
-rw-r--r--src/adapters/ws/index.ts9
-rw-r--r--src/adapters/ws/logic.test.ts33
-rw-r--r--src/adapters/ws/logic.ts18
-rw-r--r--src/app/App.svelte1
-rw-r--r--src/app/App.test.ts4
-rw-r--r--src/app/store.svelte.ts159
-rw-r--r--src/app/store.test.ts10
-rw-r--r--src/core/wire/conformance.ts4
-rw-r--r--src/features/chat/ui/ModelSelector.svelte17
-rw-r--r--src/features/plugins/adapter/http.test.ts177
-rw-r--r--src/features/plugins/adapter/http.ts230
-rw-r--r--src/features/plugins/index.ts20
-rw-r--r--src/features/plugins/logic/cascade.test.ts66
-rw-r--r--src/features/plugins/logic/cascade.ts42
-rw-r--r--src/features/plugins/logic/highlight.test.ts60
-rw-r--r--src/features/plugins/logic/highlight.ts37
-rw-r--r--src/features/plugins/logic/last-provider.test.ts70
-rw-r--r--src/features/plugins/logic/last-provider.ts31
-rw-r--r--src/features/plugins/logic/prereqs.test.ts58
-rw-r--r--src/features/plugins/logic/prereqs.ts45
-rw-r--r--src/features/plugins/logic/test-fixtures.ts30
-rw-r--r--src/features/plugins/logic/types.ts44
-rw-r--r--src/features/plugins/logic/view-model.test.ts129
-rw-r--r--src/features/plugins/logic/view-model.ts111
-rw-r--r--src/features/plugins/logic/workspace-row.test.ts67
-rw-r--r--src/features/plugins/logic/workspace-row.ts54
-rw-r--r--src/features/plugins/store.svelte.ts198
-rw-r--r--src/features/plugins/store.test.ts183
-rw-r--r--src/features/plugins/ui/PluginManager.svelte217
-rw-r--r--src/features/plugins/ui/PluginManager.test.ts284
-rw-r--r--src/features/plugins/ui/PluginRow.svelte116
-rw-r--r--src/features/plugins/ui/PluginTable.svelte85
-rw-r--r--src/features/plugins/ui/UnloadConfirmModal.svelte40
-rw-r--r--src/features/plugins/ui/WorkspacePluginModal.svelte110
-rw-r--r--src/features/plugins/ui/WorkspacePluginModal.test.ts124
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.svelte32
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte50
41 files changed, 3037 insertions, 39 deletions
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 `[email protected]` (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 `[email protected]` — 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=<active>` 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}
<App {store} onNavigate={navigate} />
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}
/>
<!-- Keyed on the workspace conversation (active tab OR draft) so the inputs
re-mount per conversation — incl. switching between drafts — and can't
diff --git a/src/app/App.test.ts b/src/app/App.test.ts
index 06e562f..69b181d 100644
--- a/src/app/App.test.ts
+++ b/src/app/App.test.ts
@@ -57,7 +57,7 @@ function fakeSocket(): FakeSocket {
function fakeFetchImpl(): typeof fetch {
return async (input: string | URL | Request): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/models")) {
+ if (/\/models(\?.*)?$/.test(url)) {
return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
@@ -491,7 +491,7 @@ describe("App component interaction tests", () => {
// network failure / unreachable backend on a new device).
const failingFetch = async (input: string | URL | Request): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/models")) {
+ if (/\/models(\?.*)?$/.test(url)) {
return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index 52136ee..bca9579 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -13,6 +13,7 @@ import type {
ConversationOpenMessage,
ConversationStatusChangedMessage,
CwdResponse,
+ ExtensionsChangedMessage,
LspStatusResponse,
McpStatusResponse,
ModelMetadata,
@@ -34,6 +35,7 @@ import type {
TestComputerResponse,
WarmRequest,
WarmResponse,
+ WorkspaceSetMessage,
} from "@dispatch/transport-contract";
import type { SubscribeMessage, SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract";
import type { ComputerEntry, ConversationStatus, ImageInput } from "@dispatch/wire";
@@ -89,6 +91,8 @@ import type {
NamedAgentsResult,
} from "../features/named-agents";
import { normalizeNamedAgentRecord, normalizeNamedAgents } from "../features/named-agents";
+import type { PluginsStore } from "../features/plugins";
+import { createPluginsHttp, createPluginsStore } from "../features/plugins";
import type { Tab, TabsState } from "../features/tabs";
import { createTabsStore, deriveTitle, type TabsStore } from "../features/tabs";
import {
@@ -543,6 +547,19 @@ export interface AppStore {
readonly namedAgentSnapshot: NamedAgentSelectionSnapshot | null;
/** Refetch the Named Agents (`GET /named-agents`). */
refreshNamedAgents(): Promise<NamedAgentsResult>;
+ // ── Plugins (plan 07) ──────────────────────────────────────────────────────
+ /**
+ * The plugin manager + per-workspace enablement store (one instance per
+ * app store). The workspaces page's manager/modal read + mutate through
+ * it; the app store feeds its `onExtensionsChanged` hook.
+ */
+ readonly plugins: PluginsStore;
+ /**
+ * Loaded provider-tier plugins effectively enabled in the ACTIVE workspace
+ * (`GET /workspaces/:id/plugins/providers-status`). `null` = unknown — the
+ * first-boot guardrail renders only on a confident `0` (§7).
+ */
+ readonly enabledProviderCount: number | null;
/**
* Create a Named Agent (`POST /named-agents`). On success the store's
* `namedAgents` list is updated (append/replace) so every consumer — the
@@ -628,6 +645,14 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
let namedAgentSnapshot = $state<NamedAgentSelectionSnapshot | null>(null);
let fatalError = $state<string | null>(null);
+ /**
+ * Loaded provider-tier plugins effectively enabled in the ACTIVE workspace
+ * (`GET /workspaces/:id/plugins/providers-status`, plan 06 §6.5). `null` =
+ * unknown (not fetched yet / endpoint missing) — the first-boot guardrail
+ * shows only on a confident `0` (§7).
+ */
+ let enabledProviderCount = $state<number | null>(null);
+
/** Replace-or-append a Named Agent into the reactive list (immutably). */
function upsertNamedAgent(agent: NamedAgent): void {
namedAgents = upsertNamedAgentList(namedAgents, agent);
@@ -661,6 +686,68 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
const indexedDBFactory = opts?.indexedDB ?? globalThis.indexedDB;
const localStorageOpt = opts?.localStorage ?? globalThis.localStorage;
+ // ── Plugins (plan 07) ──────────────────────────────────────────────────────
+ // The plugin-manager + per-workspace enablement state lives in its own
+ // feature store (one instance per app store, passed explicitly to the UI).
+ // The app store wires the server's `extensions-changed` broadcast into it
+ // (§11 multi-client coherence) and refreshes the workspace-scoped model
+ // picker + guardrail on the same event.
+ const pluginsStore = createPluginsStore(createPluginsHttp(httpBase, fetchImpl));
+
+ /**
+ * Refetch the model catalog for the ACTIVE workspace
+ * (`GET /models?workspaceId=`, plan 06 §6.5 — workspace-aware; a backend
+ * without the param returns the global catalog, which stays correct).
+ * Re-run on boot, on every workspace switch, and on `extensions-changed`
+ * (a provider toggled in another client updates the picker here too).
+ */
+ async function refreshModels(): Promise<void> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/models?workspaceId=${encodeURIComponent(activeWorkspaceId)}`,
+ );
+ if (!res.ok) return;
+ const data = (await res.json()) as ModelsResponse;
+ // Defensive: a malformed/SPA-fallback response must never poison the
+ // picker with a non-array (the old boot chain had the same exposure).
+ if (!Array.isArray(data.models)) return;
+ models = data.models;
+ modelInfo = data.modelInfo ?? {};
+ } catch (err) {
+ reportError("Failed to load model list", err);
+ }
+ }
+
+ /**
+ * Refetch the active workspace's provider status (the §6.5 first-boot
+ * guardrail signal). Guarded against a slow response losing a race with a
+ * workspace switch (only the CURRENT workspace's result lands). Non-JSON
+ * 200 (SPA fallback — endpoint not shipped) leaves it null (no prompt).
+ */
+ async function refreshProviderStatus(): Promise<void> {
+ const workspaceId = activeWorkspaceId;
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/providers-status`,
+ );
+ if (!res.ok) {
+ if (workspaceId === activeWorkspaceId) enabledProviderCount = null;
+ return;
+ }
+ const contentType = res.headers.get("content-type") ?? "";
+ if (!contentType.includes("application/json")) return;
+ const data = (await res.json()) as { enabledProviderCount?: number };
+ if (workspaceId === activeWorkspaceId) {
+ enabledProviderCount =
+ typeof data.enabledProviderCount === "number" ? data.enabledProviderCount : null;
+ }
+ } catch {
+ // Non-fatal: the guardrail just stays hidden (null) when the endpoint
+ // is unreachable.
+ if (workspaceId === activeWorkspaceId) enabledProviderCount = null;
+ }
+ }
+
const storageAdapter = createLocalStore<TabsState>("dispatch.tabs", {
storage: localStorageOpt,
});
@@ -1290,6 +1377,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
: { type: "subscribe", surfaceId, conversationId: sub.conversationId };
socket?.send(msg);
}
+ // A reconnect is a NEW connection — the server forgot this client's
+ // workspace too. Re-declare it so the re-pushed catalog is filtered
+ // (plan 06 §6.4).
+ socket?.send(workspaceSetMsg(activeWorkspaceId));
// Re-attach to every open conversation's turn stream. A turn that kept
// running while we were disconnected resumes streaming (server replays it
// from `turn-start`); one that sealed while we were gone is committed from
@@ -1305,34 +1396,41 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
watchStore.resync();
}
},
+ onExtensionsChanged(_msg: ExtensionsChangedMessage) {
+ // §11 multi-client coherence: another client (or this one) mutated the
+ // plugin set. Refetch the plugin list (+ any open workspace modal view),
+ // the workspace-scoped model catalog, and the provider guardrail. The
+ // surface catalog is NOT refetched here — the server re-pushes it
+ // (workspace-filtered) on the same broadcast.
+ pluginsStore.onExtensionsChanged();
+ void refreshModels();
+ void refreshProviderStatus();
+ },
};
if (opts?.socketFactory !== undefined) {
socketOpts.socketFactory = opts.socketFactory;
}
socket = createSurfaceSocket(socketOpts);
- // Fetch model catalog
- void fetchImpl(`${httpBase}/models`)
- .then((res) => {
- if (!res.ok) return;
- return res.json() as Promise<ModelsResponse>;
- })
- .then((data) => {
- if (data === undefined) return;
- models = data.models;
- modelInfo = data.modelInfo ?? {};
- // Do NOT silently switch the active model when the current selection is
- // missing from the catalog (e.g. a saved numbered OpenCode credential was
- // removed). The model selector surfaces it as unavailable; the user picks
- // explicitly. activeModel is still normalized through the legacy-migration
- // helper so an old `opencode/...` value is rewritten once.
- if (migrateLegacyModelName(activeModel) !== activeModel) {
- activeModel = migrateLegacyModelName(activeModel);
- }
- })
- .catch((err) => {
- reportError("Failed to load model list", err);
- });
+ // Tell the backend THIS connection's active workspace (plan 06 §6.4). The
+ // socket queues the send until the connection opens; on receipt the server
+ // re-pushes the workspace-filtered surface catalog. Also refreshed on
+ // reconnect (onReopen — a new connection is anonymous again).
+ const workspaceSetMsg = (workspaceId: string): WorkspaceSetMessage => ({
+ type: "workspace.set",
+ workspaceId,
+ });
+ socket.send(workspaceSetMsg(activeWorkspaceId));
+
+ // Fetch the model catalog (workspace-scoped — plan 06 §6.5).
+ void refreshModels();
+ // Fetch the active workspace's provider status (§6.5 first-boot guardrail).
+ void refreshProviderStatus();
+ // One-time legacy model-name migration on boot (independent of the fetch;
+ // preserved from the pre-refreshModels boot chain).
+ if (migrateLegacyModelName(activeModel) !== activeModel) {
+ activeModel = migrateLegacyModelName(activeModel);
+ }
// Fetch the discovered-computer catalog (global, like models). Empty until
// the `ssh` extension lands — a safe no-op until then (the selector shows
@@ -1423,6 +1521,11 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
},
setActiveWorkspace(workspaceId: string): void {
activeWorkspaceId = workspaceId;
+ // Tell the backend the switch (plan 06 §6.4): workspace switching is
+ // instant + client-side, so the server must be told explicitly — it
+ // re-pushes the workspace-filtered surface catalog on receipt. The send
+ // is idempotent + queued until the socket opens.
+ socket?.send(workspaceSetMsg(workspaceId));
// Reset to a fresh draft scoped to the new workspace so a new chat is
// stamped with the right `workspaceId` on `chat.send`.
const nextDraftId = randomId();
@@ -1438,6 +1541,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
void refreshReasoningEffort();
void refreshThinking();
void refreshCompactPercent();
+ // The model picker + provider guardrail are WORKSPACE-SCOPED now
+ // (plan 06 §6.5): refetch both for the new workspace.
+ void refreshModels();
+ void refreshProviderStatus();
},
get activeChat(): ChatStore {
return activeChat;
@@ -2405,6 +2512,14 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
return namedAgentSnapshot;
},
+ // ── Plugins (plan 07) ────────────────────────────────────────────────────
+ get plugins(): PluginsStore {
+ return pluginsStore;
+ },
+ get enabledProviderCount(): number | null {
+ return enabledProviderCount;
+ },
+
async refreshNamedAgents(): Promise<NamedAgentsResult> {
try {
const res = await fetchImpl(`${httpBase}/named-agents`);
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index f8b878a..d798406 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -128,7 +128,7 @@ function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch {
"body" in input
? await input.clone().text()
: null;
- if (url.endsWith("/models")) {
+ if (/\/models(\?.*)?$/.test(url)) {
return new Response(JSON.stringify({ models }), { status: 200 });
}
if (url.endsWith("/named-agents") && method === "GET") {
@@ -575,7 +575,7 @@ describe("createAppStore", () => {
const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
fetchedUrls.push(url);
- if (url.endsWith("/models")) {
+ if (/\/models(\?.*)?$/.test(url)) {
return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
@@ -806,7 +806,7 @@ describe("createAppStore", () => {
init?: RequestInit,
): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/models")) {
+ if (/\/models(\?.*)?$/.test(url)) {
return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
@@ -1439,7 +1439,7 @@ describe("createAppStore", () => {
const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
fetchedUrls.push(url);
- if (url.endsWith("/models")) {
+ if (/\/models(\?.*)?$/.test(url)) {
return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
@@ -2299,7 +2299,7 @@ describe("createAppStore — vision settings (global)", () => {
fetchImpl: async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/models")) {
+ if (/\/models(\?.*)?$/.test(url)) {
return new Response(
JSON.stringify({
models: ["kimi/k2", "umans/glm-5.2"],
diff --git a/src/core/wire/conformance.ts b/src/core/wire/conformance.ts
index 16558cd..5dbaae4 100644
--- a/src/core/wire/conformance.ts
+++ b/src/core/wire/conformance.ts
@@ -91,6 +91,8 @@ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string {
return "conversation.statusChanged";
case "conversation.compacted":
return "conversation.compacted";
+ case "extensions-changed":
+ return "extensions-changed";
default:
return msg satisfies never;
}
@@ -118,6 +120,8 @@ export function assertWsClientMessageExhaustive(msg: WsClientMessage): string {
return "chat.queue";
case "chat.queue.cancel":
return "chat.queue.cancel";
+ case "workspace.set":
+ return "workspace.set";
default:
return msg satisfies never;
}
diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte
index c66d3dd..a9b0cc9 100644
--- a/src/features/chat/ui/ModelSelector.svelte
+++ b/src/features/chat/ui/ModelSelector.svelte
@@ -22,6 +22,7 @@
namedAgents = [],
namedAgentSnapshot = null,
onSelectNamedAgent,
+ zeroProviders = false,
}: {
models: readonly string[];
selected: string;
@@ -39,6 +40,12 @@
namedAgentSnapshot?: NamedAgentSelectionSnapshot | null;
/** Called when a Named Agent is selected (the selector resolves it to a raw model + snapshot). */
onSelectNamedAgent?: (snapshot: NamedAgentSelectionSnapshot) => void;
+ /**
+ * First-boot guardrail (§7): true when the ACTIVE workspace has ZERO
+ * enabled providers (plan 06's `providers-status`) — renders a visible
+ * prompt above the picker instead of a silently empty one.
+ */
+ zeroProviders?: boolean;
} = $props();
// Whether the current selection is a Named Agent snapshot (the conversation was
@@ -121,6 +128,16 @@
</script>
<div class="flex flex-col gap-2">
+ {#if zeroProviders}
+ <!-- First-boot guardrail (§7): a workspace with zero enabled providers
+ shows a visible prompt instead of a silently empty picker. -->
+ <div class="alert alert-warning text-sm" role="alert">
+ <span>
+ No providers enabled for this workspace — open the workspace <strong>Plugins</strong> to
+ enable one.
+ </span>
+ </div>
+ {/if}
<select
class="select w-full"
value={currentProvider}
diff --git a/src/features/plugins/adapter/http.test.ts b/src/features/plugins/adapter/http.test.ts
new file mode 100644
index 0000000..b1fe477
--- /dev/null
+++ b/src/features/plugins/adapter/http.test.ts
@@ -0,0 +1,177 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createPluginsHttp } from "./http";
+
+/** A stub fetch that records requests and replies from a handler. */
+function stubFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
+ const impl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ return await handler(url, init);
+ });
+ return impl as unknown as typeof fetch;
+}
+
+const okJson = (body: unknown): Response =>
+ ({ ok: true, status: 200, json: async () => body }) as unknown as Response;
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("createPluginsHttp — manager routes (plan 05)", () => {
+ it("GET /plugins returns the parsed list (null on failure)", async () => {
+ const fetch1 = stubFetch(() =>
+ okJson({ loaded: [], disabledWithReason: [], discovered: [], missing: [] }),
+ );
+ const http = createPluginsHttp("http://x", fetch1);
+ await expect(http.list()).resolves.toEqual({
+ loaded: [],
+ disabledWithReason: [],
+ discovered: [],
+ missing: [],
+ });
+
+ const fetch2 = stubFetch(() => ({ ok: false, status: 503 }) as unknown as Response);
+ await expect(createPluginsHttp("http://x", fetch2).list()).resolves.toBeNull();
+ });
+
+ it("POST /plugins/scan posts to the scan route", async () => {
+ let seenUrl = "";
+ let seenInit: RequestInit | undefined;
+ const fetch1 = stubFetch((url, init) => {
+ seenUrl = url;
+ seenInit = init;
+ return okJson({ root: "/plugins", newPlugins: [], invalid: [], incompatible: [] });
+ });
+ const http = createPluginsHttp("http://x", fetch1);
+ const result = await http.scan();
+ expect(result.ok).toBe(true);
+ expect(seenUrl).toBe("http://x/plugins/scan");
+ expect(seenInit?.method).toBe("POST");
+ });
+
+ it("POST /plugins/:id/unload sends the force flag in the body", async () => {
+ let seenBody: string | undefined;
+ const fetch1 = stubFetch((_url, init) => {
+ seenBody = init?.body as string;
+ return okJson({ ok: true, action: "unload", id: "mcp" });
+ });
+ const http = createPluginsHttp("http://x", fetch1);
+ await http.unload("mcp", true);
+ expect(JSON.parse(seenBody ?? "{}")).toEqual({ force: true });
+ });
+
+ it("maps a blocked mutation (409) to ok:false with reason + dependents", async () => {
+ const fetch1 = stubFetch(
+ () =>
+ ({
+ ok: false,
+ status: 409,
+ json: async () => ({
+ ok: false,
+ action: "unload",
+ id: "heartbeat",
+ reason: "cascade-needs-force",
+ message: "blocked",
+ dependents: ["transport-http"],
+ }),
+ }) as unknown as Response,
+ );
+ const http = createPluginsHttp("http://x", fetch1);
+ const result = await http.unload("heartbeat", false);
+ expect(result).toEqual({
+ ok: false,
+ error: "blocked",
+ reason: "cascade-needs-force",
+ dependents: ["transport-http"],
+ });
+ });
+
+ it("load and reload hit their routes with the encoded id", async () => {
+ const urls: string[] = [];
+ const fetch1 = stubFetch((url) => {
+ urls.push(url);
+ return okJson({ ok: true, action: "load", id: "a/b" });
+ });
+ const http = createPluginsHttp("http://x", fetch1);
+ await http.load("a/b");
+ await http.reload("a/b");
+ expect(urls).toEqual(["http://x/plugins/a%2Fb/load", "http://x/plugins/a%2Fb/reload"]);
+ });
+});
+
+describe("createPluginsHttp — workspace routes (plan 06 §6.6)", () => {
+ it("GET /workspaces/:id/plugins returns the parsed response (null on failure)", async () => {
+ const fetch1 = stubFetch(() =>
+ okJson({ workspaceId: "ws", plugins: [], enabledProviderCount: 2 }),
+ );
+ const http = createPluginsHttp("http://x", fetch1);
+ await expect(http.workspacePlugins("ws")).resolves.toEqual({
+ workspaceId: "ws",
+ plugins: [],
+ enabledProviderCount: 2,
+ });
+
+ const fetch2 = stubFetch(() => ({ ok: false, status: 503 }) as unknown as Response);
+ await expect(createPluginsHttp("http://x", fetch2).workspacePlugins("ws")).resolves.toBeNull();
+ });
+
+ it("PUT /workspaces/:id/plugins/:pluginId sends the state body", async () => {
+ let seenUrl = "";
+ let seenBody: string | undefined;
+ const fetch1 = stubFetch((url, init) => {
+ seenUrl = url;
+ seenBody = init?.body as string;
+ return okJson({ workspaceId: "ws", pluginId: "mcp", state: "enabled" });
+ });
+ const http = createPluginsHttp("http://x", fetch1);
+ const result = await http.setWorkspaceState("ws", "mcp", "enabled");
+ expect(result.ok).toBe(true);
+ expect(seenUrl).toBe("http://x/workspaces/ws/plugins/mcp");
+ expect(JSON.parse(seenBody ?? "{}")).toEqual({ state: "enabled" });
+ });
+
+ it("maps a 409 refusal (core-locked) to ok:false with the reason", async () => {
+ const fetch1 = stubFetch(
+ () =>
+ ({
+ ok: false,
+ status: 409,
+ json: async () => ({
+ workspaceId: "ws",
+ pluginId: "kernel",
+ reason: "core-locked",
+ message: "core is locked",
+ }),
+ }) as unknown as Response,
+ );
+ const http = createPluginsHttp("http://x", fetch1);
+ const result = await http.setWorkspaceState("ws", "kernel", "disabled");
+ expect(result).toEqual({ ok: false, error: "core is locked", reason: "core-locked" });
+ });
+
+ it("GET providers-status returns the parsed response", async () => {
+ let seenUrl = "";
+ const fetch1 = stubFetch((url) => {
+ seenUrl = url;
+ return okJson({ workspaceId: "ws", enabledProviderCount: 0 });
+ });
+ const http = createPluginsHttp("http://x", fetch1);
+ await expect(http.providersStatus("ws")).resolves.toEqual({
+ workspaceId: "ws",
+ enabledProviderCount: 0,
+ });
+ expect(seenUrl).toBe("http://x/workspaces/ws/plugins/providers-status");
+ });
+
+ it("network throws degrade reads to null and mutations to ok:false", async () => {
+ const boom = vi.fn(async () => {
+ throw new Error("down");
+ }) as unknown as typeof fetch;
+ const http = createPluginsHttp("http://x", boom);
+ await expect(http.list()).resolves.toBeNull();
+ await expect(http.providersStatus("ws")).resolves.toBeNull();
+ await expect(http.setWorkspaceState("ws", "mcp", "enabled")).resolves.toMatchObject({
+ ok: false,
+ });
+ });
+});
diff --git a/src/features/plugins/adapter/http.ts b/src/features/plugins/adapter/http.ts
new file mode 100644
index 0000000..7814f4f
--- /dev/null
+++ b/src/features/plugins/adapter/http.ts
@@ -0,0 +1,230 @@
+import type {
+ PluginListResponse,
+ PluginOpResponse,
+ PluginScanResponse,
+ SetWorkspacePluginStateBlocked,
+ SetWorkspacePluginStateRequest,
+ SetWorkspacePluginStateResponse,
+ WorkspacePluginProvidersStatusResponse,
+ WorkspacePluginsResponse,
+} from "@dispatch/transport-contract";
+
+/**
+ * Plugin HTTP effects — the injected edge that talks to the backend's plugin
+ * manager routes (plan 05) + per-workspace enablement routes (plan 06 §6.6).
+ * Mirrors the workspaces adapter's pattern: `httpBase` + an injected
+ * `fetchImpl` (testable without the network); reads return data or a safe
+ * `null` on failure (non-fatal), mutations return a typed result so a caller
+ * can surface the backend's `{ reason, message }` block.
+ *
+ * Endpoints:
+ * - `GET /plugins` → {@link PluginListResponse} (the two manager tables + missing strip)
+ * - `POST /plugins/scan` → {@link PluginScanResponse} (manual scan; never auto-loads)
+ * - `POST /plugins/:id/load` → {@link PluginOpResponse}
+ * - `POST /plugins/:id/unload` body `{ force }` → {@link PluginOpResponse}
+ * - `POST /plugins/:id/reload` → {@link PluginOpResponse}
+ * - `GET /workspaces/:id/plugins` → {@link WorkspacePluginsResponse} (READ triggers
+ * read-through materialization)
+ * - `PUT /workspaces/:id/plugins/:pluginId` body `{ state }` →
+ * {@link SetWorkspacePluginStateResponse} (409 `core-locked` / `unloaded`)
+ * - `GET /workspaces/:id/plugins/providers-status` →
+ * {@link WorkspacePluginProvidersStatusResponse} (zero-enabled-providers guardrail)
+ */
+
+/** Outcome of a plugin-manager mutation (load / unload / reload). */
+export type PluginOpResult =
+ | { readonly ok: true; readonly response: Extract<PluginOpResponse, { ok: true }> }
+ | {
+ readonly ok: false;
+ readonly error: string;
+ readonly reason?: string;
+ /** For `cascade-needs-force`: the loaded dependents a force would remove. */
+ readonly dependents?: readonly string[];
+ };
+
+/** Outcome of a per-workspace state write (`PUT .../plugins/:pluginId`). */
+export type WorkspaceStateResult =
+ | { readonly ok: true; readonly response: SetWorkspacePluginStateResponse }
+ | { readonly ok: false; readonly error: string; readonly reason?: string };
+
+/** Outcome of the manual scan (`POST /plugins/scan`). */
+export type PluginScanResult =
+ | { readonly ok: true; readonly response: PluginScanResponse }
+ | { readonly ok: false; readonly error: string };
+
+export interface PluginsHttp {
+ list(): Promise<PluginListResponse | null>;
+ scan(): Promise<PluginScanResult>;
+ load(id: string): Promise<PluginOpResult>;
+ unload(id: string, force: boolean): Promise<PluginOpResult>;
+ reload(id: string): Promise<PluginOpResult>;
+ /** Read one workspace's effective plugin states (triggers materialization). */
+ workspacePlugins(workspaceId: string): Promise<WorkspacePluginsResponse | null>;
+ setWorkspaceState(
+ workspaceId: string,
+ pluginId: string,
+ state: "enabled" | "disabled" | "unset",
+ ): Promise<WorkspaceStateResult>;
+ /** The zero-enabled-providers signal for the active workspace (§6.5). */
+ providersStatus(workspaceId: string): Promise<WorkspacePluginProvidersStatusResponse | null>;
+}
+
+async function errorBody(res: Response): Promise<{
+ error?: string;
+ reason?: string;
+ dependents?: readonly string[];
+ message?: string;
+}> {
+ try {
+ return (await res.json()) as {
+ error?: string;
+ reason?: string;
+ dependents?: readonly string[];
+ message?: string;
+ };
+ } catch {
+ return {};
+ }
+}
+
+/**
+ * Map a plugin-manager block/error response to a `PluginOpResult`. The
+ * backend's blocked shape is `{ ok: false, reason, message, dependents? }`;
+ * other failures degrade to `{ error }` only.
+ */
+async function opResult(res: Response): Promise<PluginOpResult> {
+ const body = await errorBody(res);
+ const message = body.message ?? body.error ?? `HTTP ${res.status}`;
+ return {
+ ok: false,
+ error: message,
+ ...(body.reason !== undefined ? { reason: body.reason } : {}),
+ ...(body.dependents !== undefined ? { dependents: body.dependents } : {}),
+ };
+}
+
+export function createPluginsHttp(httpBase: string, fetchImpl: typeof fetch): PluginsHttp {
+ const post = (path: string, body?: unknown): Promise<Response> =>
+ fetchImpl(`${httpBase}${path}`, {
+ method: "POST",
+ ...(body !== undefined
+ ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
+ : {}),
+ });
+
+ return {
+ async list(): Promise<PluginListResponse | null> {
+ try {
+ const res = await fetchImpl(`${httpBase}/plugins`);
+ if (!res.ok) return null;
+ return (await res.json()) as PluginListResponse;
+ } catch {
+ return null;
+ }
+ },
+
+ async scan(): Promise<PluginScanResult> {
+ try {
+ const res = await post("/plugins/scan");
+ if (!res.ok)
+ return { ok: false, error: (await errorBody(res)).error ?? `HTTP ${res.status}` };
+ return { ok: true, response: (await res.json()) as PluginScanResponse };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Scan request failed" };
+ }
+ },
+
+ async load(id): Promise<PluginOpResult> {
+ try {
+ const res = await post(`/plugins/${encodeURIComponent(id)}/load`);
+ if (!res.ok) return await opResult(res);
+ return {
+ ok: true,
+ response: (await res.json()) as Extract<PluginOpResponse, { ok: true }>,
+ };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Load request failed" };
+ }
+ },
+
+ async unload(id, force): Promise<PluginOpResult> {
+ try {
+ const res = await post(`/plugins/${encodeURIComponent(id)}/unload`, { force });
+ if (!res.ok) return await opResult(res);
+ return {
+ ok: true,
+ response: (await res.json()) as Extract<PluginOpResponse, { ok: true }>,
+ };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Unload request failed" };
+ }
+ },
+
+ async reload(id): Promise<PluginOpResult> {
+ try {
+ const res = await post(`/plugins/${encodeURIComponent(id)}/reload`);
+ if (!res.ok) return await opResult(res);
+ return {
+ ok: true,
+ response: (await res.json()) as Extract<PluginOpResponse, { ok: true }>,
+ };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Reload request failed" };
+ }
+ },
+
+ async workspacePlugins(workspaceId): Promise<WorkspacePluginsResponse | null> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins`,
+ );
+ if (!res.ok) return null;
+ return (await res.json()) as WorkspacePluginsResponse;
+ } catch {
+ return null;
+ }
+ },
+
+ async setWorkspaceState(workspaceId, pluginId, state): Promise<WorkspaceStateResult> {
+ const body: SetWorkspacePluginStateRequest = { state };
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/${encodeURIComponent(pluginId)}`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+ if (!res.ok) {
+ const blocked = (await errorBody(res)) as Partial<SetWorkspacePluginStateBlocked> & {
+ error?: string;
+ };
+ return {
+ ok: false,
+ error: blocked.message ?? blocked.error ?? `HTTP ${res.status}`,
+ ...(blocked.reason !== undefined ? { reason: blocked.reason } : {}),
+ };
+ }
+ return { ok: true, response: (await res.json()) as SetWorkspacePluginStateResponse };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set plugin state request failed",
+ };
+ }
+ },
+
+ async providersStatus(workspaceId): Promise<WorkspacePluginProvidersStatusResponse | null> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/providers-status`,
+ );
+ if (!res.ok) return null;
+ return (await res.json()) as WorkspacePluginProvidersStatusResponse;
+ } catch {
+ return null;
+ }
+ },
+ };
+}
diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts
new file mode 100644
index 0000000..0f2abbc
--- /dev/null
+++ b/src/features/plugins/index.ts
@@ -0,0 +1,20 @@
+export type {
+ PluginOpResult,
+ PluginScanResult,
+ PluginsHttp,
+ WorkspaceStateResult,
+} from "./adapter/http";
+export { createPluginsHttp } from "./adapter/http";
+export { computeCascade } from "./logic/cascade";
+export type { HighlightSets } from "./logic/highlight";
+export { dependencyHighlightSets } from "./logic/highlight";
+export { isLastProvider, unloadBlockReason } from "./logic/last-provider";
+export { autoCheckBackendPrereqs } from "./logic/prereqs";
+export type { PluginNode, PluginSection } from "./logic/types";
+export { TIER_ORDER, tierLabel } from "./logic/types";
+export { groupByTier, pluginNodes, splitSections } from "./logic/view-model";
+export { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "./logic/workspace-row";
+export type { PluginsStore, WorkspacePluginsView } from "./store.svelte";
+export { createPluginsStore } from "./store.svelte";
+export { default as PluginManager } from "./ui/PluginManager.svelte";
+export { default as WorkspacePluginModal } from "./ui/WorkspacePluginModal.svelte";
diff --git a/src/features/plugins/logic/cascade.test.ts b/src/features/plugins/logic/cascade.test.ts
new file mode 100644
index 0000000..109c08b
--- /dev/null
+++ b/src/features/plugins/logic/cascade.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vitest";
+import { computeCascade } from "./cascade";
+import { node } from "./test-fixtures";
+
+describe("computeCascade", () => {
+ it("returns [] when the target has no dependents", () => {
+ const nodes = [node({ id: "a" }), node({ id: "b" })];
+ expect(computeCascade("a", nodes)).toEqual([]);
+ });
+
+ it("returns the direct loaded dependents", () => {
+ const nodes = [
+ node({ id: "heartbeat" }),
+ node({ id: "transport-http", dependsOn: ["heartbeat"] }),
+ ];
+ expect(computeCascade("heartbeat", nodes)).toEqual(["transport-http"]);
+ });
+
+ it("is transitive (dependent of dependent)", () => {
+ const nodes = [
+ node({ id: "a" }),
+ node({ id: "b", dependsOn: ["a"] }),
+ node({ id: "c", dependsOn: ["b"] }),
+ ];
+ expect(computeCascade("a", nodes)).toEqual(["b", "c"]);
+ });
+
+ it("does NOT traverse through an UNLOADED intermediate (loaded-subgraph closure — server mirror)", () => {
+ // c depends on b, b is unloaded: unloading a cascades to b only (b is not
+ // collected — unloaded) and does NOT reach c (its dep chain is already unmet).
+ const nodes = [
+ node({ id: "a" }),
+ node({ id: "b", loaded: false, section: "discovered", dependsOn: ["a"] }),
+ node({ id: "c", dependsOn: ["b"] }),
+ ];
+ expect(computeCascade("a", nodes)).toEqual([]);
+ });
+
+ it("ignores plugins that merely share a dependency (direction matters)", () => {
+ const nodes = [node({ id: "dep" }), node({ id: "x", dependsOn: ["dep"] })];
+ expect(computeCascade("x", nodes)).toEqual([]);
+ });
+
+ it("handles diamond dependencies without duplicates", () => {
+ const nodes = [
+ node({ id: "root" }),
+ node({ id: "left", dependsOn: ["root"] }),
+ node({ id: "right", dependsOn: ["root"] }),
+ node({ id: "bottom", dependsOn: ["left", "right"] }),
+ ];
+ expect(computeCascade("root", nodes)).toEqual(["bottom", "left", "right"]);
+ });
+
+ it("returns [] for a target that is not in the list", () => {
+ expect(computeCascade("ghost", [node({ id: "a" })])).toEqual([]);
+ });
+
+ it("is deterministic (alphabetical order)", () => {
+ const nodes = [
+ node({ id: "root" }),
+ node({ id: "zeta", dependsOn: ["root"] }),
+ node({ id: "alpha", dependsOn: ["root"] }),
+ ];
+ expect(computeCascade("root", nodes)).toEqual(["alpha", "zeta"]);
+ });
+});
diff --git a/src/features/plugins/logic/cascade.ts b/src/features/plugins/logic/cascade.ts
new file mode 100644
index 0000000..0cc6834
--- /dev/null
+++ b/src/features/plugins/logic/cascade.ts
@@ -0,0 +1,42 @@
+import type { PluginNode } from "./types";
+
+/**
+ * The transitive set of LOADED dependents that would also be unloaded if
+ * `targetId` were unloaded — the §8 cascade-uncheck warning's list ("unloading
+ * X also unchecks Y, Z"). Client mirror of the backend's
+ * `computeUnloadCascade` (plan 05): the reverse-dependency closure computed
+ * over the LOADED subgraph only (an unloaded intermediate breaks the chain —
+ * a loaded dependent behind it is already unmet and is not part of THIS
+ * cascade). The server still enforces the real cascade; the FE computes the
+ * same set to warn before the request goes out.
+ *
+ * Pure input → output; deterministic order (alphabetical by id) so tests and
+ * renderings are stable.
+ */
+export function computeCascade(targetId: string, nodes: readonly PluginNode[]): readonly string[] {
+ // Reverse edges within the loaded subgraph: dep id → loaded dependent ids.
+ const dependentsOf = new Map<string, string[]>();
+ for (const node of nodes) {
+ if (!node.loaded) continue;
+ for (const dep of node.dependsOn) {
+ const list = dependentsOf.get(dep);
+ if (list) list.push(node.id);
+ else dependentsOf.set(dep, [node.id]);
+ }
+ }
+
+ // BFS over reverse edges, collecting every reached loaded dependent.
+ const out: string[] = [];
+ const seen = new Set<string>([targetId]);
+ const queue = [targetId];
+ while (queue.length > 0) {
+ const current = queue.shift() as string;
+ for (const dependent of dependentsOf.get(current) ?? []) {
+ if (seen.has(dependent)) continue;
+ seen.add(dependent);
+ out.push(dependent);
+ queue.push(dependent);
+ }
+ }
+ return out.sort((a, b) => a.localeCompare(b));
+}
diff --git a/src/features/plugins/logic/highlight.test.ts b/src/features/plugins/logic/highlight.test.ts
new file mode 100644
index 0000000..616f7ad
--- /dev/null
+++ b/src/features/plugins/logic/highlight.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+import { dependencyHighlightSets } from "./highlight";
+import { node } from "./test-fixtures";
+
+describe("dependencyHighlightSets", () => {
+ const nodes = [
+ node({ id: "lib", loaded: false, section: "discovered" }),
+ node({ id: "mid", dependsOn: ["lib"] }),
+ node({ id: "app", dependsOn: ["mid", "lib"] }),
+ ];
+
+ it("returns empty sets for no hover", () => {
+ const sets = dependencyHighlightSets(null, nodes);
+ expect(sets.deps.size).toBe(0);
+ expect(sets.dependents.size).toBe(0);
+ });
+
+ it("hovering an item highlights its DIRECT dependencies (one hop)", () => {
+ const sets = dependencyHighlightSets("app", nodes);
+ expect(sets.deps).toEqual(new Set(["mid", "lib"]));
+ expect(sets.dependents.size).toBe(0);
+ });
+
+ it("hovering a dependency highlights its dependents (both directions work)", () => {
+ const sets = dependencyHighlightSets("lib", nodes);
+ expect(sets.deps.size).toBe(0);
+ expect(sets.dependents).toEqual(new Set(["mid", "app"]));
+ });
+
+ it("hovering a middle node highlights BOTH directions", () => {
+ const sets = dependencyHighlightSets("mid", nodes);
+ expect(sets.deps).toEqual(new Set(["lib"]));
+ expect(sets.dependents).toEqual(new Set(["app"]));
+ });
+
+ it("is one hop — not the transitive closure", () => {
+ // app → mid → lib: hovering app highlights mid + lib (both direct deps),
+ // but hovering lib highlights only DIRECT dependents (mid, app) — already
+ // one hop. The one-hop rule shows in: a plugin two hops away via an
+ // unrelated chain is not highlighted.
+ const chain = [
+ node({ id: "a" }),
+ node({ id: "b", dependsOn: ["a"] }),
+ node({ id: "c", dependsOn: ["b"] }),
+ ];
+ expect(dependencyHighlightSets("c", chain).deps).toEqual(new Set(["b"]));
+ });
+
+ it("returns empty sets for an unknown hovered id", () => {
+ const sets = dependencyHighlightSets("ghost", nodes);
+ expect(sets.deps.size).toBe(0);
+ expect(sets.dependents.size).toBe(0);
+ });
+
+ it("works across sections (a loaded row highlights an unloaded dependency)", () => {
+ // `app` loaded, `lib` discovered: hovering app still highlights lib.
+ const sets = dependencyHighlightSets("app", nodes);
+ expect(sets.deps.has("lib")).toBe(true);
+ });
+});
diff --git a/src/features/plugins/logic/highlight.ts b/src/features/plugins/logic/highlight.ts
new file mode 100644
index 0000000..1bcfaad
--- /dev/null
+++ b/src/features/plugins/logic/highlight.ts
@@ -0,0 +1,37 @@
+import type { PluginNode } from "./types";
+
+/** The two highlight sets for the §8 bidirectional dependency hover. */
+export interface HighlightSets {
+ /** The hovered row's DIRECT dependencies (highlighted when hovering an item). */
+ readonly deps: ReadonlySet<string>;
+ /** The loaded/unloaded rows that DIRECTLY depend on the hovered row
+ * (highlighted when hovering a dependency). */
+ readonly dependents: ReadonlySet<string>;
+}
+
+const EMPTY: HighlightSets = { deps: new Set(), dependents: new Set() };
+
+/**
+ * Bidirectional hover highlighting (§8): hover any item → its DIRECT
+ * dependencies highlight; hover a dependency → its dependents highlight.
+ * One hop in each direction (the plan's grammar is "its dependencies" /
+ * "its dependents" — the visual neighborhood, not the transitive closure;
+ * the transitive closures live in `computeCascade` / `autoCheckBackendPrereqs`).
+ *
+ * Works across BOTH manager tables (a loaded row can highlight an unloaded
+ * dependency row and vice versa) — the node list spans the whole catalog.
+ * Pure input → output; `hoveredId` null (no hover) → both sets empty.
+ */
+export function dependencyHighlightSets(
+ hoveredId: string | null,
+ nodes: readonly PluginNode[],
+): HighlightSets {
+ if (hoveredId === null) return EMPTY;
+ const hovered = nodes.find((n) => n.id === hoveredId);
+ if (hovered === undefined) return EMPTY;
+ const deps = new Set(hovered.dependsOn);
+ const dependents = new Set(
+ nodes.filter((n) => n.id !== hoveredId && n.dependsOn.includes(hoveredId)).map((n) => n.id),
+ );
+ return { deps, dependents };
+}
diff --git a/src/features/plugins/logic/last-provider.test.ts b/src/features/plugins/logic/last-provider.test.ts
new file mode 100644
index 0000000..f85ba89
--- /dev/null
+++ b/src/features/plugins/logic/last-provider.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from "vitest";
+import { isLastProvider, unloadBlockReason } from "./last-provider";
+import { node } from "./test-fixtures";
+
+describe("isLastProvider", () => {
+ it("is true for a loaded provider when it is the only one", () => {
+ const nodes = [node({ id: "opencode", tier: "providers" })];
+ expect(isLastProvider("opencode", nodes)).toBe(true);
+ });
+
+ it("is false for a loaded provider when another provider is loaded", () => {
+ const nodes = [
+ node({ id: "opencode", tier: "providers" }),
+ node({ id: "kimi", tier: "providers" }),
+ ];
+ expect(isLastProvider("opencode", nodes)).toBe(false);
+ });
+
+ it("is false for a non-provider target (even when alone)", () => {
+ const nodes = [node({ id: "solo", tier: "standard" })];
+ expect(isLastProvider("solo", nodes)).toBe(false);
+ });
+
+ it("is false for an UNLOADED provider", () => {
+ const nodes = [node({ id: "opencode", tier: "providers", loaded: false })];
+ expect(isLastProvider("opencode", nodes)).toBe(false);
+ });
+
+ it("is false for an unknown id", () => {
+ expect(isLastProvider("ghost", [])).toBe(false);
+ });
+
+ it("is false for a core-only list (no providers at all)", () => {
+ const nodes = [node({ id: "kernel", tier: "core", locked: true })];
+ expect(isLastProvider("kernel", nodes)).toBe(false);
+ });
+});
+
+describe("unloadBlockReason", () => {
+ it("explains the core lock", () => {
+ const nodes = [node({ id: "kernel", tier: "core", locked: true })];
+ const reason = unloadBlockReason("kernel", nodes);
+ expect(reason).toContain("Core plugins cannot be unloaded");
+ });
+
+ it("explains the last-provider lock", () => {
+ const nodes = [node({ id: "opencode", tier: "providers" })];
+ const reason = unloadBlockReason("opencode", nodes);
+ expect(reason).toContain("last provider");
+ });
+
+ it("is null for an interactive row", () => {
+ const nodes = [
+ node({ id: "opencode", tier: "providers" }),
+ node({ id: "kimi", tier: "providers" }),
+ node({ id: "app" }),
+ ];
+ expect(unloadBlockReason("app", nodes)).toBeNull();
+ expect(unloadBlockReason("opencode", nodes)).toBeNull();
+ });
+
+ it("prefers the core lock when both would apply", () => {
+ const nodes = [node({ id: "kernel", tier: "core", locked: true })];
+ expect(unloadBlockReason("kernel", nodes)).toContain("Core");
+ });
+
+ it("is null for an unknown id", () => {
+ expect(unloadBlockReason("ghost", [])).toBeNull();
+ });
+});
diff --git a/src/features/plugins/logic/last-provider.ts b/src/features/plugins/logic/last-provider.ts
new file mode 100644
index 0000000..279df4d
--- /dev/null
+++ b/src/features/plugins/logic/last-provider.ts
@@ -0,0 +1,31 @@
+import type { PluginNode } from "./types";
+
+/**
+ * Whether unloading `targetId` would remove the LAST loaded provider — the
+ * §8 hard lock (the one unload the server refuses outright, never force-able).
+ * The UI disables the control with an explanatory tooltip; the server still
+ * enforces the rule. Pure input → output.
+ */
+export function isLastProvider(targetId: string, nodes: readonly PluginNode[]): boolean {
+ const target = nodes.find((n) => n.id === targetId);
+ if (target === undefined || !target.loaded || target.tier !== "providers") return false;
+ const loadedProviders = nodes.filter((n) => n.loaded && n.tier === "providers");
+ return loadedProviders.length <= 1;
+}
+
+/**
+ * Why the row's load/unload control is disabled, or null when it is
+ * interactive. Composes the two client-mirrored locks:
+ * - tier "core" (`locked`) → the server refuses to unload it (§3/§5);
+ * - the last loaded provider → the §8 hard lock.
+ * Missing rows have no control at all (handled separately by the UI).
+ */
+export function unloadBlockReason(targetId: string, nodes: readonly PluginNode[]): string | null {
+ const target = nodes.find((n) => n.id === targetId);
+ if (target === undefined) return null;
+ if (target.locked) return "Core plugins cannot be unloaded — they are part of the runtime.";
+ if (isLastProvider(targetId, nodes)) {
+ return "Cannot unload the last provider — Dispatch needs at least one to pick models.";
+ }
+ return null;
+}
diff --git a/src/features/plugins/logic/prereqs.test.ts b/src/features/plugins/logic/prereqs.test.ts
new file mode 100644
index 0000000..704399c
--- /dev/null
+++ b/src/features/plugins/logic/prereqs.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+import { autoCheckBackendPrereqs } from "./prereqs";
+import { node } from "./test-fixtures";
+
+describe("autoCheckBackendPrereqs", () => {
+ it("returns [] when all dependencies are already loaded", () => {
+ const nodes = [node({ id: "dep" }), node({ id: "app", dependsOn: ["dep"] })];
+ expect(autoCheckBackendPrereqs("app", nodes)).toEqual([]);
+ });
+
+ it("returns the unloaded direct dependencies (to auto-check)", () => {
+ const nodes = [
+ node({ id: "dep", loaded: false, section: "discovered" }),
+ node({ id: "other", loaded: false, section: "discovered" }),
+ node({ id: "loaded-dep" }),
+ node({ id: "app", dependsOn: ["dep", "other", "loaded-dep"] }),
+ ];
+ // Only the UNLOADED deps are returned (loaded-dep needs no auto-check).
+ expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["dep", "other"]);
+ });
+
+ it("is transitive (dep of a dep)", () => {
+ const nodes = [
+ node({ id: "base", loaded: false, section: "discovered" }),
+ node({ id: "mid", loaded: false, section: "discovered", dependsOn: ["base"] }),
+ node({ id: "app", dependsOn: ["mid"] }),
+ ];
+ // Load order: dependencies BEFORE their dependents.
+ expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["mid", "base"]);
+ });
+
+ it("is one-directional: it never pulls DEPENDENTS in (no reverse traversal)", () => {
+ // Checking `dep` must not auto-check `app` (app depends on dep, not vice versa).
+ const nodes = [
+ node({ id: "dep", loaded: false, section: "discovered" }),
+ node({ id: "app", dependsOn: ["dep"] }),
+ ];
+ expect(autoCheckBackendPrereqs("dep", nodes)).toEqual([]);
+ });
+
+ it("excludes the target itself and skips already-seen nodes (diamond)", () => {
+ const nodes = [
+ node({ id: "base", loaded: false, section: "discovered" }),
+ node({ id: "left", loaded: false, section: "discovered", dependsOn: ["base"] }),
+ node({ id: "right", loaded: false, section: "discovered", dependsOn: ["base"] }),
+ node({ id: "app", dependsOn: ["left", "right"] }),
+ ];
+ expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["left", "right", "base"]);
+ });
+
+ it("returns [] for an unknown target", () => {
+ expect(autoCheckBackendPrereqs("ghost", [node({ id: "a" })])).toEqual([]);
+ });
+
+ it("returns [] for a target with no dependencies", () => {
+ expect(autoCheckBackendPrereqs("app", [node({ id: "app" })])).toEqual([]);
+ });
+});
diff --git a/src/features/plugins/logic/prereqs.ts b/src/features/plugins/logic/prereqs.ts
new file mode 100644
index 0000000..9066413
--- /dev/null
+++ b/src/features/plugins/logic/prereqs.ts
@@ -0,0 +1,45 @@
+import type { PluginNode } from "./types";
+
+/**
+ * When CHECKING (loading) `targetId`, the transitive set of its dependencies
+ * that are NOT currently loaded — the plugins that must be auto-checked
+ * (loaded) first (§8: "Checking a frontend plugin auto-checks its required
+ * backend plugins (transitively)").
+ *
+ * One-directional by construction: only `dependsOn` edges FROM the target are
+ * followed (frontend→backend / backend→backend). The reverse direction is
+ * NEVER traversed here — backend plugins never depend on frontend ones (§8),
+ * so a dependency auto-check never pulls the other way.
+ *
+ * Returns the ids in deterministic load order (dependencies before their
+ * dependents — alphabetical within a level) so the caller can issue the load
+ * requests in a safe sequence. Pure input → output.
+ */
+export function autoCheckBackendPrereqs(
+ targetId: string,
+ nodes: readonly PluginNode[],
+): readonly string[] {
+ const byId = new Map(nodes.map((n) => [n.id, n]));
+
+ // BFS over dependency edges (target → deps), collecting UNLOADED deps.
+ const out: string[] = [];
+ const seen = new Set<string>([targetId]);
+ // Queue of [id, depth] — sorted per level so ties resolve alphabetically.
+ let frontier: string[] = [targetId];
+ while (frontier.length > 0) {
+ const next: string[] = [];
+ for (const id of frontier) {
+ const node = byId.get(id);
+ for (const dep of node?.dependsOn ?? []) {
+ if (seen.has(dep)) continue;
+ seen.add(dep);
+ next.push(dep);
+ if (!byId.get(dep)?.loaded) out.push(dep);
+ }
+ }
+ frontier = next.sort((a, b) => a.localeCompare(b));
+ }
+ // Load order: dependencies before dependents (BFS level order already is),
+ // alphabetical within a level.
+ return out;
+}
diff --git a/src/features/plugins/logic/test-fixtures.ts b/src/features/plugins/logic/test-fixtures.ts
new file mode 100644
index 0000000..e7b3f5d
--- /dev/null
+++ b/src/features/plugins/logic/test-fixtures.ts
@@ -0,0 +1,30 @@
+import type { PluginListResponse } from "@dispatch/transport-contract";
+import type { PluginNode } from "./types";
+import { pluginNodes } from "./view-model";
+
+/**
+ * Build a PluginNode directly (the graph-logic tests' fixture). Defaults:
+ * loaded, standard tier, no deps.
+ */
+export function node(overrides: Partial<PluginNode> = {}): PluginNode {
+ return {
+ id: "p",
+ name: "p",
+ version: "1.0.0",
+ tier: "standard",
+ trust: "bundled",
+ loaded: true,
+ locked: false,
+ dependsOn: [],
+ reason: null,
+ section: "loaded",
+ ...overrides,
+ };
+}
+
+/** An empty `GET /plugins` response. */
+export function emptyListResponse(): PluginListResponse {
+ return { loaded: [], disabledWithReason: [], discovered: [], missing: [] };
+}
+
+export { pluginNodes };
diff --git a/src/features/plugins/logic/types.ts b/src/features/plugins/logic/types.ts
new file mode 100644
index 0000000..401913a
--- /dev/null
+++ b/src/features/plugins/logic/types.ts
@@ -0,0 +1,44 @@
+import type { PluginTier, PluginTrustLevel } from "@dispatch/transport-contract";
+
+/** Which manager table a plugin row belongs to (the response's row kinds). */
+export type PluginSection = "loaded" | "discovered" | "missing";
+
+/**
+ * The manager's normalized view of one plugin — a single shape across the
+ * `GET /plugins` response's row kinds (loaded / disabledWithReason /
+ * discovered / missing) so the pure graph logic (cascade / prereqs /
+ * highlight / last-provider) and the manager tables work over ONE node type.
+ * Built by `pluginNodes` (`logic/view-model`) from a `PluginListResponse`.
+ */
+export interface PluginNode {
+ readonly id: string;
+ readonly name: string;
+ readonly version: string;
+ readonly tier: PluginTier;
+ /** Trust level, when the row carries one (missing rows don't). */
+ readonly trust: PluginTrustLevel | null;
+ /** Loaded and live — the manager's checkbox reflects this. */
+ readonly loaded: boolean;
+ /** True for tier "core" — the server refuses to unload it (locked control). */
+ readonly locked: boolean;
+ /** Declared dependencies (plugin ids), possibly empty. */
+ readonly dependsOn: readonly string[];
+ /** Why the plugin is not loaded (disabled-with-reason) or gone (missing). */
+ readonly reason: string | null;
+ readonly section: PluginSection;
+}
+
+/** Display labels for the classification tiers (backend-canonical values). */
+export function tierLabel(tier: PluginTier): string {
+ switch (tier) {
+ case "core":
+ return "Core";
+ case "providers":
+ return "Provider";
+ case "standard":
+ return "Standard";
+ }
+}
+
+/** Display order of the tiers within a manager table (Core first). */
+export const TIER_ORDER: readonly PluginTier[] = ["core", "providers", "standard"];
diff --git a/src/features/plugins/logic/view-model.test.ts b/src/features/plugins/logic/view-model.test.ts
new file mode 100644
index 0000000..c4a4547
--- /dev/null
+++ b/src/features/plugins/logic/view-model.test.ts
@@ -0,0 +1,129 @@
+import type { PluginListResponse } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import { emptyListResponse } from "./test-fixtures";
+import { groupByTier, pluginNodes, splitSections } from "./view-model";
+
+function listResponse(overrides: Partial<PluginListResponse> = {}): PluginListResponse {
+ return {
+ 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: ["kernel"],
+ },
+ ],
+ 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", disabledAt: "2026-01-01" }],
+ ...overrides,
+ };
+}
+
+describe("pluginNodes", () => {
+ it("normalizes every row kind into one node shape", () => {
+ const nodes = pluginNodes(listResponse());
+ expect(nodes.map((n) => n.id)).toEqual(["kernel", "opencode", "broken", "fresh", "gone"]);
+ });
+
+ it("maps loaded rows (loaded: true, locked from the row, deps kept)", () => {
+ const nodes = pluginNodes(listResponse());
+ const kernel = nodes.find((n) => n.id === "kernel");
+ expect(kernel).toMatchObject({
+ loaded: true,
+ locked: true,
+ tier: "core",
+ section: "loaded",
+ dependsOn: [],
+ reason: null,
+ });
+ const opencode = nodes.find((n) => n.id === "opencode");
+ expect(opencode).toMatchObject({ loaded: true, locked: false, dependsOn: ["kernel"] });
+ });
+
+ it("maps disabledWithReason rows (unloaded, reason kept, discovered section)", () => {
+ const nodes = pluginNodes(listResponse());
+ const broken = nodes.find((n) => n.id === "broken");
+ expect(broken).toMatchObject({
+ loaded: false,
+ locked: false,
+ reason: "activation threw",
+ section: "discovered",
+ });
+ });
+
+ it("maps discovered rows (unloaded, no reason)", () => {
+ const nodes = pluginNodes(listResponse());
+ const fresh = nodes.find((n) => n.id === "fresh");
+ expect(fresh).toMatchObject({ loaded: false, reason: null, section: "discovered" });
+ });
+
+ it("maps missing rows (greyed strip; no trust/version)", () => {
+ const nodes = pluginNodes(listResponse());
+ const gone = nodes.find((n) => n.id === "gone");
+ expect(gone).toMatchObject({
+ loaded: false,
+ section: "missing",
+ reason: "directory is gone",
+ trust: null,
+ version: "",
+ });
+ });
+
+ it("handles the empty response", () => {
+ expect(pluginNodes(emptyListResponse())).toEqual([]);
+ });
+});
+
+describe("groupByTier", () => {
+ it("clusters rows in tier display order (Core → Providers → Standard)", () => {
+ const nodes = pluginNodes(listResponse());
+ const groups = groupByTier(nodes.filter((n) => n.section === "loaded"));
+ expect(groups.map((g) => g.tier)).toEqual(["core", "providers"]);
+ expect(groups[0]?.rows.map((r) => r.id)).toEqual(["kernel"]);
+ });
+
+ it("omits empty tiers", () => {
+ const groups = groupByTier([pluginNodes(listResponse())[2] ?? []].flat());
+ expect(groups.map((g) => g.tier)).toEqual(["standard"]);
+ });
+});
+
+describe("splitSections", () => {
+ it("splits the node list into the two tables + the missing strip", () => {
+ const sections = splitSections(pluginNodes(listResponse()));
+ expect(sections.loaded.map((n) => n.id)).toEqual(["kernel", "opencode"]);
+ expect(sections.discovered.map((n) => n.id)).toEqual(["broken", "fresh"]);
+ expect(sections.missing.map((n) => n.id)).toEqual(["gone"]);
+ });
+});
diff --git a/src/features/plugins/logic/view-model.ts b/src/features/plugins/logic/view-model.ts
new file mode 100644
index 0000000..23d57d9
--- /dev/null
+++ b/src/features/plugins/logic/view-model.ts
@@ -0,0 +1,111 @@
+import type { PluginListResponse } from "@dispatch/transport-contract";
+import { type PluginNode, type PluginSection, TIER_ORDER } from "./types";
+
+/**
+ * Normalize a `GET /plugins` response into the flat `PluginNode` list the
+ * manager tables + graph logic consume. Each response row kind maps to a
+ * node:
+ * - `loaded` → section "loaded" (`loaded: true`, `locked` from the row —
+ * tier "core" is always locked);
+ * - `disabledWithReason` → section "discovered" with the refusal `reason`
+ * (present on disk, refused/failed — greyed rows with a visible why);
+ * - `discovered` → section "discovered" (`loaded: false`, unchecked);
+ * - `missing` → section "missing" (override record whose directory is gone).
+ *
+ * Pure input → output; zero DOM/fetch.
+ */
+export function pluginNodes(response: PluginListResponse): readonly PluginNode[] {
+ const nodes: PluginNode[] = [];
+ for (const row of response.loaded) {
+ nodes.push({
+ id: row.id,
+ name: row.name,
+ version: row.version,
+ tier: row.tier,
+ trust: row.trust,
+ loaded: true,
+ locked: row.locked,
+ dependsOn: row.dependsOn,
+ reason: null,
+ section: "loaded",
+ });
+ }
+ for (const row of response.disabledWithReason) {
+ nodes.push({
+ id: row.id,
+ name: row.name,
+ version: row.version,
+ tier: row.tier,
+ trust: row.trust,
+ loaded: false,
+ locked: false,
+ dependsOn: [],
+ reason: row.reason,
+ section: "discovered",
+ });
+ }
+ for (const row of response.discovered) {
+ nodes.push({
+ id: row.id,
+ name: row.name,
+ version: row.version,
+ tier: row.tier,
+ trust: row.trust,
+ loaded: false,
+ locked: false,
+ dependsOn: [],
+ reason: null,
+ section: "discovered",
+ });
+ }
+ for (const row of response.missing) {
+ nodes.push({
+ id: row.id,
+ name: row.id,
+ version: "",
+ tier: "standard",
+ trust: null,
+ loaded: false,
+ locked: false,
+ dependsOn: [],
+ reason: row.reason ?? "directory is gone",
+ section: "missing",
+ });
+ }
+ return nodes;
+}
+
+/**
+ * Cluster nodes for one manager table: tier groups in display order (Core →
+ * Providers → Standard), each tier's rows sorted by id. Tiers with no rows
+ * are omitted. Used by the loaded table's tier grouping (plan 07 §7.2).
+ */
+export function groupByTier(
+ nodes: readonly PluginNode[],
+): ReadonlyArray<{ readonly tier: PluginNode["tier"]; readonly rows: readonly PluginNode[] }> {
+ const out: { tier: PluginNode["tier"]; rows: PluginNode[] }[] = [];
+ for (const tier of TIER_ORDER) {
+ const rows = nodes.filter((n) => n.tier === tier).sort((a, b) => a.id.localeCompare(b.id));
+ if (rows.length > 0) out.push({ tier, rows });
+ }
+ // Any tier outside TIER_ORDER (defensive — the contract pins three values).
+ const known = new Set<PluginNode["tier"]>(TIER_ORDER);
+ const rest = nodes.filter((n) => !known.has(n.tier)).sort((a, b) => a.id.localeCompare(b.id));
+ if (rest.length > 0) out.push({ tier: rest[0]?.tier ?? "standard", rows: rest });
+ return out;
+}
+
+/** Split nodes into the manager's two tables + the missing strip. */
+export function splitSections(nodes: readonly PluginNode[]): {
+ readonly loaded: readonly PluginNode[];
+ readonly discovered: readonly PluginNode[];
+ readonly missing: readonly PluginNode[];
+} {
+ const bySection = (section: PluginSection): PluginNode[] =>
+ nodes.filter((n) => n.section === section);
+ return {
+ loaded: bySection("loaded"),
+ discovered: bySection("discovered"),
+ missing: bySection("missing"),
+ };
+}
diff --git a/src/features/plugins/logic/workspace-row.test.ts b/src/features/plugins/logic/workspace-row.test.ts
new file mode 100644
index 0000000..4c7d29e
--- /dev/null
+++ b/src/features/plugins/logic/workspace-row.test.ts
@@ -0,0 +1,67 @@
+import type { WorkspacePluginRow } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "./workspace-row";
+
+function row(overrides: Partial<WorkspacePluginRow> = {}): WorkspacePluginRow {
+ return {
+ pluginId: "mcp",
+ tier: "standard",
+ state: "undefined",
+ loaded: true,
+ locked: false,
+ ...overrides,
+ };
+}
+
+describe("rowChecked", () => {
+ it("is true only for an explicit enabled record", () => {
+ expect(rowChecked(row({ state: "enabled" }))).toBe(true);
+ expect(rowChecked(row({ state: "disabled" }))).toBe(false);
+ expect(rowChecked(row({ state: "undefined" }))).toBe(false);
+ });
+});
+
+describe("rowSelectable", () => {
+ it("is true for a loaded, unlocked row (all three states)", () => {
+ for (const state of ["enabled", "disabled", "undefined"] as const) {
+ expect(rowSelectable(row({ state }))).toBe(true);
+ }
+ });
+
+ it("is false for an UNLOADED plugin (§7 strict layering — greyed out)", () => {
+ expect(rowSelectable(row({ loaded: false, state: "disabled" }))).toBe(false);
+ });
+
+ it("is false for a LOCKED (core) row — the server refuses the PUT (409)", () => {
+ expect(rowSelectable(row({ tier: "core", locked: true, state: "enabled" }))).toBe(false);
+ });
+});
+
+describe("rowBlockReason", () => {
+ it("points at the global manager for unloaded plugins", () => {
+ const reason = rowBlockReason(row({ loaded: false }));
+ expect(reason).toContain("plugin manager");
+ });
+
+ it("explains the core lock", () => {
+ const reason = rowBlockReason(row({ tier: "core", locked: true }));
+ expect(reason).toContain("Core plugins are enabled by default");
+ });
+
+ it("prefers the unloaded reason when both apply", () => {
+ const reason = rowBlockReason(row({ loaded: false, tier: "core", locked: true }));
+ expect(reason).toContain("plugin manager");
+ });
+
+ it("is null for an interactive row", () => {
+ expect(rowBlockReason(row())).toBeNull();
+ });
+});
+
+describe("stateLabel", () => {
+ it("renders the three wire states in a uniform grammar", () => {
+ expect(stateLabel("enabled")).toBe("enabled");
+ expect(stateLabel("disabled")).toBe("disabled");
+ expect(stateLabel("undefined")).toBe("not set");
+ });
+});
diff --git a/src/features/plugins/logic/workspace-row.ts b/src/features/plugins/logic/workspace-row.ts
new file mode 100644
index 0000000..c85b759
--- /dev/null
+++ b/src/features/plugins/logic/workspace-row.ts
@@ -0,0 +1,54 @@
+import type { WorkspacePluginRow, WorkspacePluginStateValue } from "@dispatch/transport-contract";
+
+/**
+ * Pure helpers for the per-workspace plugin modal (plan 06 §6.6 wire rows →
+ * checkbox semantics). Zero DOM.
+ *
+ * State model (backend-canonical): `"enabled"` / `"disabled"` — an explicit
+ * record — or `"undefined"` for NO record (the blank slate; behaves disabled
+ * for Providers/Standard). Core rows arrive materialized `enabled` (Q-A).
+ */
+
+/** Whether the modal checkbox is checked for a row (explicit `enabled` only). */
+export function rowChecked(row: WorkspacePluginRow): boolean {
+ return row.state === "enabled";
+}
+
+/**
+ * Whether the row's checkbox is selectable. Two unselectable cases (§7
+ * strict layering + plan 06 §6.6):
+ * - the plugin is NOT loaded globally → greyed out and unselectable (only the
+ * global manager loads code);
+ * - the server reports the row locked (tier "core") → the server refuses the
+ * PUT (`core-locked` 409), so the control is disabled with a tooltip.
+ */
+export function rowSelectable(row: WorkspacePluginRow): boolean {
+ return row.loaded && !row.locked;
+}
+
+/**
+ * Why the row is unselectable (tooltip text), or null when interactive.
+ * Unloaded beats locked in the copy (the user's actionable next step is the
+ * global manager).
+ */
+export function rowBlockReason(row: WorkspacePluginRow): string | null {
+ if (!row.loaded) {
+ return "Not loaded globally — load it in the plugin manager first (only the manager loads code).";
+ }
+ if (row.locked) {
+ return "Core plugins are enabled by default in every workspace and managed globally.";
+ }
+ return null;
+}
+
+/** The effective-state label shown next to a row (uniform three-state grammar). */
+export function stateLabel(state: WorkspacePluginStateValue): string {
+ switch (state) {
+ case "enabled":
+ return "enabled";
+ case "disabled":
+ return "disabled";
+ case "undefined":
+ return "not set";
+ }
+}
diff --git a/src/features/plugins/store.svelte.ts b/src/features/plugins/store.svelte.ts
new file mode 100644
index 0000000..580293e
--- /dev/null
+++ b/src/features/plugins/store.svelte.ts
@@ -0,0 +1,198 @@
+import type {
+ PluginListResponse,
+ SetWorkspacePluginStateRequest,
+ WorkspacePluginRow,
+} from "@dispatch/transport-contract";
+import type { PluginOpResult, PluginScanResult, PluginsHttp } from "./adapter/http";
+import type { PluginNode } from "./logic/types";
+import { pluginNodes } from "./logic/view-model";
+
+/**
+ * Plugins store — a thin reactive wrapper over the injected `PluginsHttp`
+ * edge (the workspaces-store pattern). Owns:
+ * - the manager's plugin list (`GET /plugins`) normalized to `PluginNode`s;
+ * - ONE open workspace modal's view (`GET /workspaces/:id/plugins` — read-through
+ * materialization makes this first read the workspace's "open");
+ * - pending/error state for the mutations.
+ *
+ * Multi-client coherence (§11): the composition root calls
+ * `onExtensionsChanged()` when the server broadcasts `extensions-changed` —
+ * the store refetches the list (and the open workspace view), so every
+ * connected client converges. No ambient state: one instance per composition
+ * root, passed explicitly; no module-level mutables.
+ */
+export interface PluginsStore {
+ /** The last `GET /plugins` response (null until the first fetch lands). */
+ readonly list: PluginListResponse | null;
+ /** The normalized nodes across ALL sections (loaded + discovered + missing). */
+ readonly nodes: readonly PluginNode[];
+ readonly loading: boolean;
+ /** The last mutation/scan/load error message, or null. */
+ readonly error: string | null;
+ /** The manual scan's result (null until a scan ran), for the summary line. */
+ readonly lastScan: PluginScanResult | null;
+
+ /** Refetch `GET /plugins`. */
+ refresh(): Promise<void>;
+ /** `POST /plugins/scan` (manual; the server never auto-loads new plugins). */
+ scan(): Promise<PluginScanResult>;
+ /** `POST /plugins/:id/load`. */
+ load(id: string): Promise<PluginOpResult>;
+ /** `POST /plugins/:id/unload` with `{ force }` (the §8 force semantics). */
+ unload(id: string, force: boolean): Promise<PluginOpResult>;
+ /** `POST /plugins/:id/reload`. */
+ reload(id: string): Promise<PluginOpResult>;
+
+ /**
+ * The open workspace modal's rows (null when nothing loaded yet). Cleared
+ * IMMEDIATELY on open so a slow/failing fetch never shows the previous
+ * workspace's rows (the refreshCwd/refreshComputer pattern).
+ */
+ readonly workspaceView: WorkspacePluginsView | null;
+ /** Load/reload the workspace modal's view (`GET /workspaces/:id/plugins`). */
+ openWorkspace(workspaceId: string): Promise<void>;
+ /**
+ * Write one plugin's state in one workspace (`PUT .../plugins/:pluginId`).
+ * On success the open view's row is updated in place from the response; on
+ * failure the error is surfaced AND the view refetched (server-authoritative
+ * reconciliation — the checkbox never lies).
+ */
+ setWorkspaceState(
+ workspaceId: string,
+ pluginId: string,
+ state: SetWorkspacePluginStateRequest["state"],
+ ): Promise<void>;
+
+ /**
+ * Multi-client coherence hook (§11): the composition root calls
+ * `onExtensionsChanged()` when the server broadcasts `extensions-changed` —
+ * the store refetches the list (and the open workspace view), so every
+ * connected client converges. Fire-and-forget.
+ */
+ onExtensionsChanged(): void;
+}
+
+/** One open workspace modal's view (rows + materialized provider count). */
+export interface WorkspacePluginsView {
+ readonly workspaceId: string;
+ readonly rows: readonly WorkspacePluginRow[];
+ /** Loaded provider-tier plugins effectively enabled here (§6.5). */
+ readonly enabledProviderCount: number;
+}
+
+export function createPluginsStore(http: PluginsHttp): PluginsStore {
+ let list = $state<PluginListResponse | null>(null);
+ let loading = $state(false);
+ let error = $state<string | null>(null);
+ let lastScan = $state<PluginScanResult | null>(null);
+ let workspaceView = $state<WorkspacePluginsView | null>(null);
+
+ // Normalized nodes — derived so the graph logic re-runs only when the list
+ // changes, not on every read.
+ const nodes = $derived<readonly PluginNode[]>(list === null ? [] : pluginNodes(list));
+
+ return {
+ get list(): PluginListResponse | null {
+ return list;
+ },
+ get nodes(): readonly PluginNode[] {
+ return nodes;
+ },
+ get loading(): boolean {
+ return loading;
+ },
+ get error(): string | null {
+ return error;
+ },
+ get lastScan(): PluginScanResult | null {
+ return lastScan;
+ },
+ get workspaceView(): WorkspacePluginsView | null {
+ return workspaceView;
+ },
+
+ async refresh(): Promise<void> {
+ loading = true;
+ const data = await http.list();
+ if (data !== null) list = data;
+ loading = false;
+ },
+
+ async scan(): Promise<PluginScanResult> {
+ error = null;
+ const result = await http.scan();
+ lastScan = result;
+ if (!result.ok) error = result.error;
+ // A scan broadcasts `extensions-changed` server-side; refresh anyway so
+ // the manager is coherent even without a WS connection.
+ await this.refresh();
+ return result;
+ },
+
+ async load(id): Promise<PluginOpResult> {
+ error = null;
+ const result = await http.load(id);
+ if (!result.ok) error = result.error;
+ await this.refresh();
+ return result;
+ },
+
+ async unload(id, force): Promise<PluginOpResult> {
+ error = null;
+ const result = await http.unload(id, force);
+ if (!result.ok) error = result.error;
+ await this.refresh();
+ return result;
+ },
+
+ async reload(id): Promise<PluginOpResult> {
+ error = null;
+ const result = await http.reload(id);
+ if (!result.ok) error = result.error;
+ await this.refresh();
+ return result;
+ },
+
+ async openWorkspace(workspaceId): Promise<void> {
+ // Clear immediately so a slow/failing fetch never shows the PREVIOUS
+ // workspace's rows in the new modal.
+ workspaceView = null;
+ const data = await http.workspacePlugins(workspaceId);
+ if (data === null) {
+ error = `Failed to load plugins for workspace "${workspaceId}"`;
+ return;
+ }
+ workspaceView = {
+ workspaceId: data.workspaceId,
+ rows: data.plugins,
+ enabledProviderCount: data.enabledProviderCount,
+ };
+ },
+
+ async setWorkspaceState(workspaceId, pluginId, state): Promise<void> {
+ const result = await http.setWorkspaceState(workspaceId, pluginId, state);
+ if (result.ok) {
+ // Server-authoritative in-place row update (the response carries the
+ // resulting effective state — "undefined" after "unset").
+ if (workspaceView?.workspaceId === workspaceId) {
+ const rows = workspaceView.rows.map(
+ (row): WorkspacePluginRow =>
+ row.pluginId === pluginId ? { ...row, state: result.response.state } : row,
+ );
+ workspaceView = { ...workspaceView, rows };
+ }
+ return;
+ }
+ error = result.error;
+ // Refusal (409 core-locked / unloaded) or failure — reconcile from the
+ // server so the checkbox never lies.
+ await this.openWorkspace(workspaceId);
+ },
+
+ onExtensionsChanged(): void {
+ void this.refresh();
+ const open = workspaceView?.workspaceId;
+ if (open !== undefined) void this.openWorkspace(open);
+ },
+ };
+}
diff --git a/src/features/plugins/store.test.ts b/src/features/plugins/store.test.ts
new file mode 100644
index 0000000..be301ee
--- /dev/null
+++ b/src/features/plugins/store.test.ts
@@ -0,0 +1,183 @@
+import type { PluginListResponse, WorkspacePluginsResponse } from "@dispatch/transport-contract";
+import { describe, expect, it, vi } from "vitest";
+import type {
+ PluginOpResult,
+ PluginScanResult,
+ PluginsHttp,
+ WorkspaceStateResult,
+} from "./adapter/http";
+import { createPluginsStore } from "./store.svelte";
+
+const listResponse: PluginListResponse = {
+ loaded: [
+ {
+ id: "kernel",
+ name: "Kernel",
+ version: "1.0.0",
+ tier: "core",
+ trust: "bundled",
+ locked: true,
+ dependsOn: [],
+ },
+ ],
+ disabledWithReason: [],
+ discovered: [
+ {
+ id: "fresh",
+ name: "Fresh",
+ version: "1.0.0",
+ tier: "standard",
+ trust: "external",
+ dir: "/plugins/fresh",
+ },
+ ],
+ missing: [],
+};
+
+const wsResponse: WorkspacePluginsResponse = {
+ workspaceId: "ws",
+ plugins: [
+ { pluginId: "kernel", tier: "core", state: "enabled", loaded: true, locked: true },
+ { pluginId: "mcp", tier: "standard", state: "undefined", loaded: true, locked: false },
+ ],
+ enabledProviderCount: 1,
+};
+
+/** A fake PluginsHttp with a controllable list + workspace response. */
+function fakeHttp(opts: {
+ list?: PluginListResponse | null;
+ workspacePlugins?: WorkspacePluginsResponse | null;
+}) {
+ // `=== undefined` (not `??`) so an explicit `null` override defeats the default.
+ const list = () => (opts.list === undefined ? listResponse : opts.list);
+ const ws = () => (opts.workspacePlugins === undefined ? wsResponse : opts.workspacePlugins);
+ const opOk = (action: "load" | "unload" | "reload") => ({ ok: true, action, id: "x" }) as const;
+ return {
+ list: vi.fn(async (): Promise<PluginListResponse | null> => list()),
+ scan: vi.fn(
+ async (): Promise<PluginScanResult> => ({
+ ok: true,
+ response: { root: "/plugins", newPlugins: [], invalid: [], incompatible: [] },
+ }),
+ ),
+ load: vi.fn(async (): Promise<PluginOpResult> => ({ ok: true, response: opOk("load") })),
+ unload: vi.fn(async (): Promise<PluginOpResult> => ({ ok: true, response: opOk("unload") })),
+ reload: vi.fn(async (): Promise<PluginOpResult> => ({ ok: true, response: opOk("reload") })),
+ workspacePlugins: vi.fn(async (): Promise<WorkspacePluginsResponse | null> => ws()),
+ setWorkspaceState: vi.fn(
+ async (): Promise<WorkspaceStateResult> => ({
+ ok: true,
+ response: { workspaceId: "ws", pluginId: "mcp", state: "enabled" },
+ }),
+ ),
+ providersStatus: vi.fn(async () => ({ workspaceId: "ws", enabledProviderCount: 1 })),
+ } satisfies PluginsHttp;
+}
+
+describe("createPluginsStore — manager list", () => {
+ it("fetches + normalizes the list into nodes", async () => {
+ const store = createPluginsStore(fakeHttp({}));
+ expect(store.list).toBeNull();
+ expect(store.nodes).toEqual([]);
+ await store.refresh();
+ expect(store.list).toEqual(listResponse);
+ expect(store.nodes.map((n) => n.id)).toEqual(["kernel", "fresh"]);
+ });
+
+ it("mutation results are surfaced and the list is refetched", async () => {
+ const http = fakeHttp({});
+ const store = createPluginsStore(http);
+ await store.refresh();
+ const result = await store.unload("kernel", false);
+ expect(result.ok).toBe(true);
+ expect(http.unload).toHaveBeenCalledWith("kernel", false);
+ expect(http.list).toHaveBeenCalledTimes(2);
+ });
+
+ it("scan records the result + refreshes", async () => {
+ const http = fakeHttp({});
+ const store = createPluginsStore(http);
+ await store.scan();
+ expect(store.lastScan?.ok).toBe(true);
+ expect(http.scan).toHaveBeenCalledOnce();
+ expect(http.list).toHaveBeenCalledTimes(1); // refresh after scan (initial list fetch absent)
+ });
+
+ it("a mutation error lands in store.error", async () => {
+ const http = fakeHttp({});
+ http.unload = vi.fn(async (): Promise<PluginOpResult> => ({ ok: false, error: "blocked" }));
+ const store = createPluginsStore(http);
+ await store.unload("kernel", false);
+ expect(store.error).toBe("blocked");
+ });
+});
+
+describe("createPluginsStore — workspace view", () => {
+ it("openWorkspace loads the rows + enabled provider count", async () => {
+ const store = createPluginsStore(fakeHttp({}));
+ await store.openWorkspace("ws");
+ expect(store.workspaceView).toEqual({
+ workspaceId: "ws",
+ rows: wsResponse.plugins,
+ enabledProviderCount: 1,
+ });
+ });
+
+ it("setWorkspaceState updates the row in place from the response", async () => {
+ const store = createPluginsStore(fakeHttp({}));
+ await store.openWorkspace("ws");
+ await store.setWorkspaceState("ws", "mcp", "enabled");
+ const row = store.workspaceView?.rows.find((r) => r.pluginId === "mcp");
+ expect(row?.state).toBe("enabled");
+ });
+
+ it("a refused setWorkspaceState refetches (server-authoritative reconcile)", async () => {
+ const http = fakeHttp({});
+ http.setWorkspaceState = vi.fn(
+ async (): Promise<WorkspaceStateResult> => ({
+ ok: false,
+ error: "core is locked",
+ reason: "core-locked",
+ }),
+ );
+ const store = createPluginsStore(http);
+ await store.openWorkspace("ws");
+ await store.setWorkspaceState("ws", "kernel", "disabled");
+ expect(store.error).toBe("core is locked");
+ expect(http.workspacePlugins).toHaveBeenCalledTimes(2);
+ });
+
+ it("a failed openWorkspace clears the view (no stale rows from another workspace)", async () => {
+ const http = fakeHttp({ workspacePlugins: null });
+ const store = createPluginsStore(http);
+ await store.openWorkspace("other");
+ expect(store.workspaceView).toBeNull();
+ expect(store.error).toContain("other");
+ });
+});
+
+describe("createPluginsStore — multi-client coherence (§11)", () => {
+ it("onExtensionsChanged refetches the list + the open workspace view", async () => {
+ const http = fakeHttp({});
+ const store = createPluginsStore(http);
+ await store.openWorkspace("ws");
+ expect(http.list).toHaveBeenCalledTimes(0);
+ expect(http.workspacePlugins).toHaveBeenCalledTimes(1);
+
+ store.onExtensionsChanged();
+ await vi.waitFor(() => {
+ expect(http.list).toHaveBeenCalledTimes(1);
+ expect(http.workspacePlugins).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it("onExtensionsChanged does not refetch a workspace view when none is open", async () => {
+ const http = fakeHttp({});
+ const store = createPluginsStore(http);
+ store.onExtensionsChanged();
+ await vi.waitFor(() => {
+ expect(http.list).toHaveBeenCalledTimes(1);
+ expect(http.workspacePlugins).toHaveBeenCalledTimes(0);
+ });
+ });
+});
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 @@
+<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 } 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) ────────────────────────────
+ const sections = $derived(splitSections(plugins.nodes));
+ let hoveredId = $state<string | null>(null);
+ const highlight = $derived(dependencyHighlightSets(hoveredId, plugins.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..457bd29
--- /dev/null
+++ b/src/features/plugins/ui/PluginRow.svelte
@@ -0,0 +1,116 @@
+<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.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/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..bce885b
--- /dev/null
+++ b/src/features/plugins/ui/WorkspacePluginModal.svelte
@@ -0,0 +1,110 @@
+<script lang="ts">
+ import { onMount } from "svelte";
+ import type { PluginsStore } from "../store.svelte";
+ import { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "../logic/workspace-row";
+
+ let {
+ workspaceId,
+ workspaceTitle,
+ plugins,
+ onClose,
+ }: {
+ workspaceId: string;
+ /** The workspace's display title (modal heading). */
+ workspaceTitle: string;
+ plugins: PluginsStore;
+ 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");
+ }
+</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}
+
+ <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>
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> = {}): 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 @@
<script lang="ts">
import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire";
import { untrack } from "svelte";
+ import { WorkspacePluginModal, type PluginsStore } from "../../plugins";
import type { WorkspaceStore } from "../store.svelte";
import { relativeTime } from "../logic/view-model";
import { workspacePath } from "../logic/route";
@@ -12,6 +13,7 @@
onNavigate,
computers,
hasActive,
+ plugins,
}: {
ws: WorkspaceEntry;
store: WorkspaceStore;
@@ -25,6 +27,12 @@
* `workspaceHasActiveConversations`. Absent → no indicator (e.g. tests).
*/
hasActive?: (workspaceId: string) => boolean;
+ /**
+ * Optional plugin store (plan 07): when present, the card renders a
+ * "Plugins" button opening THIS workspace's enablement modal. Absent
+ * (tests) → no button.
+ */
+ plugins?: PluginsStore;
} = $props();
// Whether at least one conversation in this workspace is currently active
@@ -118,6 +126,11 @@
}
}
+ // ── Per-workspace plugin modal (plan 07 §7.3) ─────────────────────────────
+ // Enable/disable what THIS workspace uses. Unloaded plugins are greyed out
+ // + unselectable there — only the global manager (page header) loads code.
+ let pluginsOpen = $state(false);
+
// ── Delete ─────────────────────────────────────────────────────────────────
let deleting = $state(false);
@@ -263,6 +276,16 @@
>
Open
</a>
+ {#if plugins}
+ <button
+ type="button"
+ class="btn btn-outline btn-sm ml-2"
+ onclick={() => (pluginsOpen = true)}
+ title="Choose which plugins this workspace uses"
+ >
+ Plugins
+ </button>
+ {/if}
</div>
{#if cwdError}
@@ -277,3 +300,12 @@
<p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p>
{/if}
</li>
+
+{#if pluginsOpen && plugins}
+ <WorkspacePluginModal
+ workspaceId={ws.id}
+ workspaceTitle={ws.title}
+ {plugins}
+ onClose={() => (pluginsOpen = false)}
+ />
+{/if}
diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte
index d97eab7..548d733 100644
--- a/src/features/workspaces/ui/WorkspacesHome.svelte
+++ b/src/features/workspaces/ui/WorkspacesHome.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import type { ComputerEntry } from "@dispatch/wire";
+ import { PluginManager, type PluginsStore } from "../../plugins";
import type { WorkspaceStore } from "../store.svelte";
import { isValidSlug, workspacePath } from "../logic/route";
import WorkspaceCard from "./WorkspaceCard.svelte";
@@ -10,6 +11,7 @@
onNavigate,
computers,
hasActive,
+ plugins,
}: {
store: WorkspaceStore;
onNavigate: (path: string) => void;
@@ -20,6 +22,13 @@
* Wired by the composition root to the app store. Absent → no indicator.
*/
hasActive?: (workspaceId: string) => boolean;
+ /**
+ * The plugin manager store (plan 07): drives the page-header global
+ * "Plugins" button + the per-card workspace modals (forwarded to
+ * {@link WorkspaceCard}). Optional (absent in tests that don't exercise
+ * plugins) → the buttons don't render.
+ */
+ plugins?: PluginsStore;
} = $props();
onMount(() => {
@@ -31,6 +40,10 @@
const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug));
+ // Global plugin manager modal (plan 07 §7.2): opened from the header
+ // button; the ONLY surface that loads/unloads code (strict layering).
+ let managerOpen = $state(false);
+
function createWorkspace(): void {
const slug = newSlug.trim();
if (!isValidSlug(slug)) {
@@ -46,16 +59,28 @@
<div class="mx-auto flex h-screen w-full max-w-3xl flex-col gap-4 p-6">
<header class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Workspaces</h1>
- <a
- href="/default"
- class="btn btn-ghost btn-sm"
- onclick={(e) => {
- e.preventDefault();
- onNavigate("/default");
- }}
- >
- Default
- </a>
+ <div class="flex items-center gap-2">
+ <a
+ href="/default"
+ class="btn btn-ghost btn-sm"
+ onclick={(e) => {
+ e.preventDefault();
+ onNavigate("/default");
+ }}
+ >
+ Default
+ </a>
+ {#if plugins}
+ <button
+ type="button"
+ class="btn btn-outline btn-sm"
+ onclick={() => (managerOpen = true)}
+ title="Open the plugin manager (load/unload plugins globally)"
+ >
+ Plugins
+ </button>
+ {/if}
+ </div>
</header>
<form
@@ -102,9 +127,14 @@
{onNavigate}
{computers}
{...(hasActive ? { hasActive } : {})}
+ {...(plugins ? { plugins } : {})}
/>
{/each}
</ul>
{/if}
</div>
</div>
+
+{#if managerOpen && plugins}
+ <PluginManager {plugins} onClose={() => (managerOpen = false)} />
+{/if}