summaryrefslogtreecommitdiffhomepage
path: root/src/features/plugins
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-05 04:42:20 +0900
committerAdam Malczewski <[email protected]>2026-09-05 04:42:20 +0900
commit5e5f02c67d0c165dd93f8277a3815d51a540d996 (patch)
tree2b98579d25f3f16204feaf25ddac2746e4bbb934 /src/features/plugins
parent2f282f1a5c784dff97af7c936ccb43ca2b63f03f (diff)
downloaddispatch-web-5e5f02c67d0c165dd93f8277a3815d51a540d996.tar.gz
dispatch-web-5e5f02c67d0c165dd93f8277a3815d51a540d996.zip
feat(layout): plan 03 — frontend plugin ABI + handshake + load lifecycle
Diffstat (limited to 'src/features/plugins')
-rw-r--r--src/features/plugins/index.ts7
-rw-r--r--src/features/plugins/logic/types.ts7
-rw-r--r--src/features/plugins/logic/view-model.test.ts71
-rw-r--r--src/features/plugins/logic/view-model.ts29
-rw-r--r--src/features/plugins/store.svelte.ts20
-rw-r--r--src/features/plugins/ui/PluginManager.svelte10
-rw-r--r--src/features/plugins/ui/PluginRow.svelte4
7 files changed, 142 insertions, 6 deletions
diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts
index 0f2abbc..7a9d1a0 100644
--- a/src/features/plugins/index.ts
+++ b/src/features/plugins/index.ts
@@ -10,9 +10,12 @@ 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 type {
+ PluginNode,
+ PluginSection,
+} from "./logic/types";
export { TIER_ORDER, tierLabel } from "./logic/types";
-export { groupByTier, pluginNodes, splitSections } from "./logic/view-model";
+export { groupByTier, pluginNodes, splitSections, withFrontendStates } 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";
diff --git a/src/features/plugins/logic/types.ts b/src/features/plugins/logic/types.ts
index 702483b..d5fa4bb 100644
--- a/src/features/plugins/logic/types.ts
+++ b/src/features/plugins/logic/types.ts
@@ -25,6 +25,13 @@ export interface PluginNode {
readonly dependsOn: readonly string[];
/** Why the plugin is not loaded (disabled-with-reason) or gone (missing). */
readonly reason: string | null;
+ /**
+ * The frontend-plugin (plan 03) issue line for the manager row, when the
+ * plugin's FE half is incompatible ("incompatible frontend plugin") or
+ * failed (`frontend failed: <error>`). Absent = the FE half is fine (or
+ * the plugin declares no frontend section).
+ */
+ readonly frontendIssue?: string;
readonly section: PluginSection;
}
diff --git a/src/features/plugins/logic/view-model.test.ts b/src/features/plugins/logic/view-model.test.ts
index 680ba94..661cade 100644
--- a/src/features/plugins/logic/view-model.test.ts
+++ b/src/features/plugins/logic/view-model.test.ts
@@ -1,7 +1,7 @@
import type { PluginListResponse, PluginTier } from "@dispatch/transport-contract";
import { describe, expect, it } from "vitest";
import { emptyListResponse, node } from "./test-fixtures";
-import { groupByTier, pluginNodes, splitSections } from "./view-model";
+import { groupByTier, pluginNodes, splitSections, withFrontendStates } from "./view-model";
function listResponse(overrides: Partial<PluginListResponse> = {}): PluginListResponse {
return {
@@ -225,3 +225,72 @@ describe("splitSections", () => {
expect(sections.missing.map((n) => n.id)).toEqual(["gone"]);
});
});
+
+// ── withFrontendStates (plan 03) — the manager rows' FE-half issues ─────────
+
+describe("withFrontendStates", () => {
+ function listWith(...ids: readonly string[]): PluginListResponse {
+ return {
+ loaded: ids.map((id) => ({
+ id,
+ name: id,
+ version: "1.0.0",
+ tier: "standard" as const,
+ trust: "local" as const,
+ locked: false,
+ dependsOn: [],
+ })),
+ disabledWithReason: [],
+ discovered: [],
+ missing: [],
+ };
+ }
+
+ it("renders the plan-mandated grammar for an incompatible FE half", () => {
+ const nodes = pluginNodes(listWith("bad"));
+ const states = new Map([["bad", { kind: "incompatible", apiVersion: "^2" } as const]]);
+ const out = withFrontendStates(nodes, states);
+ expect(out[0]?.frontendIssue).toBe("incompatible frontend plugin");
+ });
+
+ it("renders the activation error for a failed FE half", () => {
+ const nodes = pluginNodes(listWith("broken"));
+ const states = new Map([["broken", { kind: "failed", error: "boom" } as const]]);
+ const out = withFrontendStates(nodes, states);
+ expect(out[0]?.frontendIssue).toBe("frontend failed: boom");
+ });
+
+ it("a loaded FE half adds no issue line; absent states add none", () => {
+ const nodes = pluginNodes(listWith("ok", "no-fe"));
+ const states = new Map([["ok", { kind: "loaded" } as const]]);
+ const out = withFrontendStates(nodes, states);
+ expect(out.find((n) => n.id === "ok")?.frontendIssue).toBeUndefined();
+ expect(out.find((n) => n.id === "no-fe")?.frontendIssue).toBeUndefined();
+ });
+
+ it("never annotates non-loaded sections (discovered / missing rows)", () => {
+ const response: PluginListResponse = {
+ loaded: [],
+ disabledWithReason: [],
+ discovered: [
+ {
+ id: "dis",
+ name: "dis",
+ version: "1.0.0",
+ tier: "standard",
+ trust: "local",
+ dir: "/plugins/dis",
+ },
+ ],
+ missing: [],
+ };
+ const states = new Map([["dis", { kind: "failed", error: "boom" } as const]]);
+ const out = withFrontendStates(pluginNodes(response), states);
+ expect(out[0]?.frontendIssue).toBeUndefined();
+ });
+
+ it("an empty state map is the identity (no allocation on the hot path)", () => {
+ const nodes = pluginNodes(listWith("a"));
+ expect(withFrontendStates(nodes, new Map())).toBe(nodes);
+ });
+});
diff --git a/src/features/plugins/logic/view-model.ts b/src/features/plugins/logic/view-model.ts
index 20d7d68..5a66ffa 100644
--- a/src/features/plugins/logic/view-model.ts
+++ b/src/features/plugins/logic/view-model.ts
@@ -1,4 +1,5 @@
import type { PluginListResponse } from "@dispatch/transport-contract";
+import type { FrontendPluginState } from "../../../core/plugin-abi";
import { normalizeTier, type PluginNode, type PluginSection, TIER_ORDER } from "./types";
/**
@@ -79,6 +80,34 @@ export function pluginNodes(response: PluginListResponse): readonly PluginNode[]
}
/**
+ * Merge the frontend-plugin (plan 03) per-plugin FE states into the node
+ * list — the manager rows' `frontendIssue` line. Pure input → output:
+ * - `incompatible` → the plan-mandated "incompatible frontend plugin"
+ * grammar (the backend apiVersion-mismatch wording, FE half);
+ * - `failed` → the activation error (the FE half failed; the backend half
+ * stays loaded).
+ * Loaded rows only — the FE states never apply to discovered/missing rows.
+ */
+export function withFrontendStates(
+ nodes: readonly PluginNode[],
+ states: ReadonlyMap<string, FrontendPluginState>,
+): readonly PluginNode[] {
+ if (states.size === 0) return nodes;
+ return nodes.map((node) => {
+ if (node.section !== "loaded") return node;
+ const state = states.get(node.id);
+ if (state === undefined) return node;
+ const issue =
+ state.kind === "incompatible"
+ ? "incompatible frontend plugin"
+ : state.kind === "failed"
+ ? `frontend failed: ${state.error}`
+ : null;
+ return issue === null ? node : { ...node, frontendIssue: issue };
+ });
+}
+
+/**
* 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).
diff --git a/src/features/plugins/store.svelte.ts b/src/features/plugins/store.svelte.ts
index 580293e..fe48940 100644
--- a/src/features/plugins/store.svelte.ts
+++ b/src/features/plugins/store.svelte.ts
@@ -3,6 +3,7 @@ import type {
SetWorkspacePluginStateRequest,
WorkspacePluginRow,
} from "@dispatch/transport-contract";
+import type { FrontendPluginState } from "../../core/plugin-abi";
import type { PluginOpResult, PluginScanResult, PluginsHttp } from "./adapter/http";
import type { PluginNode } from "./logic/types";
import { pluginNodes } from "./logic/view-model";
@@ -70,6 +71,16 @@ export interface PluginsStore {
* connected client converges. Fire-and-forget.
*/
onExtensionsChanged(): void;
+
+ /**
+ * The frontend-plugin (plan 03) per-plugin FE states — reported by the
+ * plugin host after every driver sync. Rendered by the manager rows:
+ * `incompatible` → "incompatible frontend plugin" (the backend-mismatch
+ * grammar), `failed` → the activation error. Absent = no FE section.
+ */
+ readonly frontendStates: ReadonlyMap<string, FrontendPluginState>;
+ /** The plugin host's post-sync report (the only writer). */
+ setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void;
}
/** One open workspace modal's view (rows + materialized provider count). */
@@ -86,6 +97,7 @@ export function createPluginsStore(http: PluginsHttp): PluginsStore {
let error = $state<string | null>(null);
let lastScan = $state<PluginScanResult | null>(null);
let workspaceView = $state<WorkspacePluginsView | null>(null);
+ let frontendStates = $state<ReadonlyMap<string, FrontendPluginState>>(new Map());
// Normalized nodes — derived so the graph logic re-runs only when the list
// changes, not on every read.
@@ -194,5 +206,13 @@ export function createPluginsStore(http: PluginsHttp): PluginsStore {
const open = workspaceView?.workspaceId;
if (open !== undefined) void this.openWorkspace(open);
},
+
+ get frontendStates(): ReadonlyMap<string, FrontendPluginState> {
+ return frontendStates;
+ },
+
+ setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void {
+ frontendStates = states;
+ },
};
}
diff --git a/src/features/plugins/ui/PluginManager.svelte b/src/features/plugins/ui/PluginManager.svelte
index cd3d542..fd2a8a7 100644
--- a/src/features/plugins/ui/PluginManager.svelte
+++ b/src/features/plugins/ui/PluginManager.svelte
@@ -6,7 +6,7 @@
import { autoCheckBackendPrereqs } from "../logic/prereqs";
import { dependencyHighlightSets } from "../logic/highlight";
import { unloadBlockReason } from "../logic/last-provider";
- import { splitSections } from "../logic/view-model";
+ import { splitSections, withFrontendStates } from "../logic/view-model";
import PluginTable from "./PluginTable.svelte";
import UnloadConfirmModal from "./UnloadConfirmModal.svelte";
@@ -17,9 +17,13 @@
});
// ── Derived sections + hover highlighting (§8) ────────────────────────────
- const sections = $derived(splitSections(plugins.nodes));
+ // The FE-half states (plan 03) merge into the nodes here — one reactive
+ // derivation over both inputs (the normalized nodes + the plugin host's
+ // post-sync state map).
+ const nodes = $derived(withFrontendStates(plugins.nodes, plugins.frontendStates));
+ const sections = $derived(splitSections(nodes));
let hoveredId = $state<string | null>(null);
- const highlight = $derived(dependencyHighlightSets(hoveredId, plugins.nodes));
+ const highlight = $derived(dependencyHighlightSets(hoveredId, nodes));
function highlightFor(id: string): "dep" | "dependent" | null {
if (highlight.deps.has(id)) return "dep";
diff --git a/src/features/plugins/ui/PluginRow.svelte b/src/features/plugins/ui/PluginRow.svelte
index 457bd29..73e0b18 100644
--- a/src/features/plugins/ui/PluginRow.svelte
+++ b/src/features/plugins/ui/PluginRow.svelte
@@ -83,6 +83,10 @@
{#if node.reason !== null}
<span class="text-xs text-error">{node.reason}</span>
{/if}
+ {#if node.frontendIssue !== undefined}
+ <!-- plan 03: the FE half's state — "incompatible frontend plugin" or the activation error -->
+ <span class="text-xs text-error">{node.frontendIssue}</span>
+ {/if}
{#if node.dependsOn.length > 0}
<span class="text-xs opacity-60">depends on: {node.dependsOn.join(", ")}</span>
{/if}