summaryrefslogtreecommitdiffhomepage
path: root/src/features/plugins/logic
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 /src/features/plugins/logic
parent33c7245f18346553f660434bc3ac589cff3e80c2 (diff)
downloaddispatch-web-3a4922a05930b0dc22740bf5621910803e8f275e.tar.gz
dispatch-web-3a4922a05930b0dc22740bf5621910803e8f275e.zip
feat(plugin-system): plan 07 frontend plugin manager ui
Diffstat (limited to 'src/features/plugins/logic')
-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
14 files changed, 844 insertions, 0 deletions
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";
+ }
+}