summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-05 17:33:06 +0900
committerAdam Malczewski <[email protected]>2026-09-05 17:33:06 +0900
commit254f90a4081a3f15dfe54277da922579dbaee254 (patch)
treef5f4e409196109baf938cd9d557e244242df18ce
parent535215bfd1fee6087613108f42d28cd2807184d3 (diff)
downloaddispatch-web-254f90a4081a3f15dfe54277da922579dbaee254.tar.gz
dispatch-web-254f90a4081a3f15dfe54277da922579dbaee254.zip
feat(layout): plan 04 — ABI layout.applyOps + reset affordance
-rw-r--r--src/App.svelte14
-rw-r--r--src/app/plugin-host.svelte.ts11
-rw-r--r--src/app/store.svelte.ts19
-rw-r--r--src/core/plugin-abi/driver.test.ts6
-rw-r--r--src/core/plugin-abi/load-diff.test.ts18
-rw-r--r--src/core/plugin-abi/types.ts22
-rw-r--r--src/core/plugin-abi/version.test.ts33
-rw-r--r--src/core/plugin-abi/version.ts29
-rw-r--r--src/features/layout/adapter/abi-port.test.ts96
-rw-r--r--src/features/layout/adapter/abi-port.ts60
-rw-r--r--src/features/layout/adapter/http.test.ts45
-rw-r--r--src/features/layout/adapter/http.ts62
-rw-r--r--src/features/layout/index.ts6
-rw-r--r--src/features/layout/store.svelte.ts25
-rw-r--r--src/features/layout/store.test.ts71
-rw-r--r--src/features/plugins/index.ts1
-rw-r--r--src/features/plugins/logic/types.ts22
-rw-r--r--src/features/plugins/ui/ResetLayoutConfirm.svelte30
-rw-r--r--src/features/plugins/ui/WorkspacePluginModal.svelte56
-rw-r--r--src/features/plugins/ui/WorkspacePluginModal.test.ts81
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.svelte10
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte10
22 files changed, 698 insertions, 29 deletions
diff --git a/src/App.svelte b/src/App.svelte
index 151e752..d19a392 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
import { App, createAppStore } from "./app";
import { createHistoryAdapter } from "./adapters/history";
+ import type { WorkspaceLayoutPort } from "./features/plugins";
import {
createWorkspaceHttp,
createWorkspaceStore,
@@ -65,6 +66,18 @@
function navigate(path: string): void {
history.navigate(path);
}
+
+ // plan 04 §4.4: the workspace plugins modal's "Reset layout to default"
+ // affordance. The port gates on an ACTIVE layout provider (read through the
+ // store's reactive getters, so enable/disable mid-session flips the button)
+ // and resets via the layout store (`DELETE /workspaces/:id/layout` → the
+ // server re-seeds → the WS broadcast re-renders every client).
+ const layoutPort: WorkspaceLayoutPort = {
+ get providerActive() {
+ return store.layout.providerState === "present";
+ },
+ reset: (workspaceId) => store.layout.resetToDefault(workspaceId),
+ };
</script>
{#if route.kind === "home"}
@@ -74,6 +87,7 @@
computers={store.computers}
hasActive={(id) => store.workspaceHasActiveConversations(id)}
plugins={store.plugins}
+ layout={layoutPort}
/>
{:else}
<App {store} onNavigate={navigate} />
diff --git a/src/app/plugin-host.svelte.ts b/src/app/plugin-host.svelte.ts
index 729a8aa..8054005 100644
--- a/src/app/plugin-host.svelte.ts
+++ b/src/app/plugin-host.svelte.ts
@@ -28,6 +28,7 @@ import {
usablePluginIds,
} from "../core/plugin-abi";
import {
+ type LayoutAbiPort,
registerPluginView,
unregisterViewsByPlugin,
type ViewComponent,
@@ -60,6 +61,12 @@ export interface PluginHostOptions {
/** The ACTIVE workspace provider (read per sync — workspace.set trigger). */
readonly activeWorkspaceId: () => string;
readonly surfaces: FrontendSurfacesPorts;
+ /**
+ * The layout port (plan 04 §4.5) — the ABI's `api.layout` (applyOps to the
+ * ACTIVE workspace's provider + getTree, null in no-provider mode). Built
+ * by the composition root over the layout store + ops HTTP edge.
+ */
+ readonly layout: LayoutAbiPort;
}
export interface PluginHost {
@@ -108,6 +115,10 @@ export function createPluginHost(opts: PluginHostOptions): PluginHost {
info: (msg) => console.info(`[frontend-plugin:${pluginId}] ${msg}`),
warn: (msg) => console.warn(`[frontend-plugin:${pluginId}] ${msg}`),
},
+ // plan 04 §4.5: the layout port — the composition-root-built adapter
+ // over the layout store + the ops HTTP edge (the plugin never sees
+ // either; only this typed port).
+ layout: opts.layout,
};
}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index 8a80160..c1d265f 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -84,7 +84,12 @@ import type {
} from "../features/heartbeat";
import { normalizeHeartbeatConfig, normalizeHeartbeatRuns } from "../features/heartbeat";
import type { LayoutStore } from "../features/layout";
-import { createLayoutHttp, createLayoutStore, listPanelViews } from "../features/layout";
+import {
+ createLayoutAbiPort,
+ createLayoutHttp,
+ createLayoutStore,
+ listPanelViews,
+} from "../features/layout";
import type {
NamedAgent,
NamedAgentDeleteResult,
@@ -723,8 +728,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
// ephemeral, logged once, no error UI). Probes run on boot, on every
// workspace switch, and on `extensions-changed` (§2.6); the one-way
// localStorage migration (§2.7) arms on the boot probe.
+ const layoutHttp = createLayoutHttp(httpBase, fetchImpl);
const layoutStore = createLayoutStore({
- http: createLayoutHttp(httpBase, fetchImpl),
+ http: layoutHttp,
workspaceId: activeWorkspaceId,
storage: localStorageOpt,
knownViews: listPanelViews().map((v) => v.id),
@@ -1417,6 +1423,15 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
invokeSurfaceOp(surfaceId, actionId, payload, conversationId),
getSpec: (surfaceId) => surfaceSpecOp(surfaceId),
},
+ // plan 04 §4.5: the plugins' `api.layout` port — applyOps posts to the
+ // ACTIVE workspace's provider (the ops route) and feeds the returned
+ // tree back through the store (instant local echo; the WS broadcast is
+ // every client's authoritative copy); getTree exposes the active tree
+ // only while a provider is PRESENT (null = no-provider mode).
+ layout: createLayoutAbiPort({
+ store: layoutStore,
+ postOps: (workspaceId, ops) => layoutHttp.postOps(workspaceId, ops),
+ }),
});
const socketOpts: SurfaceSocketOptions = {
diff --git a/src/core/plugin-abi/driver.test.ts b/src/core/plugin-abi/driver.test.ts
index e8c8e7e..b02c76b 100644
--- a/src/core/plugin-abi/driver.test.ts
+++ b/src/core/plugin-abi/driver.test.ts
@@ -41,7 +41,7 @@ function harness(): Harness {
return mod;
},
buildHostApi: (pluginId) => ({
- abiVersion: "1",
+ abiVersion: "1.1",
registerView: () => () => {},
surfaces: {
subscribe: () => {},
@@ -50,6 +50,10 @@ function harness(): Harness {
getSpec: () => null,
},
logger: { info: () => {}, warn: () => {} },
+ layout: {
+ applyOps: async () => ({ ok: false, error: "no layout provider active" }),
+ getTree: () => null,
+ },
...(pluginId === undefined ? {} : {}),
}),
unregisterViewsByPlugin: (pluginId) => sweeps.push(pluginId),
diff --git a/src/core/plugin-abi/load-diff.test.ts b/src/core/plugin-abi/load-diff.test.ts
index 157b93f..f8ebf6c 100644
--- a/src/core/plugin-abi/load-diff.test.ts
+++ b/src/core/plugin-abi/load-diff.test.ts
@@ -160,8 +160,24 @@ describe("decideFrontendLoads — the load diff (boot / extensions-changed / wor
it("the handshake checks against FRONTEND_ABI_VERSION", () => {
// A range that includes the host version passes — proving the version
// constant feeds the check.
- expect(FRONTEND_ABI_VERSION).toBe("1");
+ expect(FRONTEND_ABI_VERSION).toBe("1.1");
const d = decideFrontendLoads([c("ok")], new Map());
expect(d.incompatible).toEqual([]);
});
+
+ it("the additive bump keeps v1 plugins loading (plan 04: ^1 spans 1.1)", () => {
+ // c() declares apiVersion "^1" — a bare-major range is a
+ // major-compatibility claim, so the 1.1 host (layout member added) still
+ // loads every v1 plugin.
+ const d = decideFrontendLoads([c("v1-plugin")], new Map());
+ expect(d.toLoad.map((x) => x.id)).toEqual(["v1-plugin"]);
+ expect(d.incompatible).toEqual([]);
+ });
+
+ it("plugins requiring the 1.1 ABI (^1.1) load on the 1.1 host", () => {
+ const layout = { ...c("layout-plugin"), apiVersion: "^1.1" };
+ const d = decideFrontendLoads([layout], new Map());
+ expect(d.toLoad.map((x) => x.id)).toEqual(["layout-plugin"]);
+ expect(d.incompatible).toEqual([]);
+ });
});
diff --git a/src/core/plugin-abi/types.ts b/src/core/plugin-abi/types.ts
index 14abf6e..bcb8139 100644
--- a/src/core/plugin-abi/types.ts
+++ b/src/core/plugin-abi/types.ts
@@ -15,6 +15,7 @@
* FORCES the importing plugin's pluginId attribution (the host wraps the
* registry call), which is what makes unload-time sweeps exact.
*/
+import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract";
import type { SurfaceSpec } from "@dispatch/ui-contract";
import type { FRONTEND_ABI_VERSION } from "./version.js";
@@ -63,8 +64,25 @@ export interface FrontendHostAPI {
readonly getSpec: (surfaceId: string) => SurfaceSpec | null;
};
readonly logger: { info: (msg: string) => void; warn: (msg: string) => void };
- // plan 04 ADDS `layout: { applyOps(...) }` here — an additive ABI bump
- // (v1 plugins keep working; range-checked per the handshake).
+ /**
+ * Edit the ACTIVE workspace's layout through the provider (plan 04 §4.5 —
+ * the additive ABI bump; v1 plugins keep working, a plugin REQUIRING the
+ * layout member declares `frontend.apiVersion: "^1.1"`). A thin adapter
+ * over `POST /workspaces/:id/layout/ops` (the same kit `applyOps` the
+ * service exposes — one code path, two doors):
+ * - the ops are the `@dispatch/layout-contract` vocabulary (prefer the
+ * idempotent `ensure*` ops; address by node ID, never by numeric path);
+ * - the batch is ATOMIC — any failed op persists nothing and the result
+ * names it (`failedOp`);
+ * - no layout provider active (the route 404s) → the typed no-provider
+ * error `{ ok: false, error: "no layout provider active" }` — handle it;
+ * the provider may also load later (retry on workspace switches).
+ */
+ readonly layout: {
+ readonly applyOps: (ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>;
+ /** The ACTIVE workspace's tree; `null` in no-provider mode. */
+ readonly getTree: () => LayoutTree | null;
+ };
}
/**
diff --git a/src/core/plugin-abi/version.test.ts b/src/core/plugin-abi/version.test.ts
index 2620847..ab4d34b 100644
--- a/src/core/plugin-abi/version.test.ts
+++ b/src/core/plugin-abi/version.test.ts
@@ -2,21 +2,26 @@ import { describe, expect, it } from "vitest";
import { FRONTEND_ABI_VERSION, isFrontendCompatible } from "./version.js";
describe("FRONTEND_ABI_VERSION", () => {
- it("is 1 (plan 03)", () => {
- expect(FRONTEND_ABI_VERSION).toBe("1");
+ it("is 1.1 (plan 04 — the additive layout member bumped the minor)", () => {
+ expect(FRONTEND_ABI_VERSION).toBe("1.1");
});
});
-describe("isFrontendCompatible — handshake accept/reject matrix", () => {
- it("accepts the canonical authoring range ^1", () => {
+describe("isFrontendCompatible — handshake accept/reject matrix (host 1.1.0)", () => {
+ it("accepts the canonical authoring range ^1 (v1 plugins keep working — the plan-04 promise)", () => {
expect(isFrontendCompatible("^1")).toBe(true);
expect(isFrontendCompatible("^1.0")).toBe(true);
expect(isFrontendCompatible("^1.0.0")).toBe(true);
});
- it("accepts the bare major version and exact matches", () => {
+ it("accepts the bare major version (a major-compatibility claim: additive bumps never strand it)", () => {
expect(isFrontendCompatible("1")).toBe(true);
- expect(isFrontendCompatible("1.0.0")).toBe(true);
+ expect(isFrontendCompatible("~1")).toBe(true);
+ });
+
+ it("accepts plugins that REQUIRE the 1.1 ABI (^1.1 — the layout-declaring grammar)", () => {
+ expect(isFrontendCompatible("^1.1")).toBe(true);
+ expect(isFrontendCompatible("^1.1.0")).toBe(true);
});
it("accepts wildcard and >= ranges", () => {
@@ -34,9 +39,11 @@ describe("isFrontendCompatible — handshake accept/reject matrix", () => {
expect(isFrontendCompatible("^0.1.0")).toBe(false);
});
- it("rejects a tilde range of an older minor", () => {
+ it("rejects a tilde/pin at an OLDER explicit minor (explicit pins reject additive bumps)", () => {
expect(isFrontendCompatible("~0.9.0")).toBe(false);
- expect(isFrontendCompatible("~1.0.0")).toBe(true);
+ expect(isFrontendCompatible("~1.0.0")).toBe(false);
+ expect(isFrontendCompatible("1.0.0")).toBe(false);
+ expect(isFrontendCompatible("^1.0")).not.toBe(false); // caret 1.x tracks the major
});
it("rejects malformed ranges (fail closed, never throws)", () => {
@@ -46,9 +53,13 @@ describe("isFrontendCompatible — handshake accept/reject matrix", () => {
expect(isFrontendCompatible("1.0.0.0")).toBe(false);
});
- it("rejects a future patch-level host (the host version pins the answer)", () => {
- // The host version defaults to "1.0.0"; "~1.0.0" includes it.
- expect(isFrontendCompatible("~1.0.0", "1.0.0")).toBe(true);
+ it("the host version pins the answer (injectable for tests)", () => {
+ // At a hypothetical 1.0.0 host, ^1.1 (needing the layout member) excludes;
+ // at 1.1.0 it includes — the gate is real, not decorative.
+ expect(isFrontendCompatible("^1.1", "1.0.0")).toBe(false);
+ expect(isFrontendCompatible("^1.1", "1.1.0")).toBe(true);
+ // "~1.0.0" pins minor 0: excludes a 1.1.0 host, includes a 1.0.x host.
expect(isFrontendCompatible("~1.0.0", "1.1.0")).toBe(false);
+ expect(isFrontendCompatible("~1.0.0", "1.0.0")).toBe(true);
});
});
diff --git a/src/core/plugin-abi/version.ts b/src/core/plugin-abi/version.ts
index becae87..e5a0168 100644
--- a/src/core/plugin-abi/version.ts
+++ b/src/core/plugin-abi/version.ts
@@ -1,7 +1,9 @@
/**
* The frontend plugin ABI version + the semver handshake (plan 03 §3.2).
*
- * FRONTEND_ABI_VERSION is bumped on BREAKING ABI changes only. A plugin's
+ * FRONTEND_ABI_VERSION is bumped on BREAKING ABI changes only — an ADDITIVE
+ * member bumps the MINOR (plan 04 added `layout`: "1" → "1.1"; v1 plugins
+ * keep working, see the bare-major rule below). A plugin's
* `manifest.frontend.apiVersion` is a semver RANGE that must include the
* host's version; a mismatch lists the plugin as "incompatible frontend
* plugin" in the manager UI and its module is NEVER imported.
@@ -12,17 +14,26 @@
* major-only versions/ranges ("1", "^1") — the ABI's version grammar is
* single-number, normalized here to x.y.z with zeros — because a frontend
* ABI ("1") is not a kernel release ("0.1.0") and authors declare `"^1"`.
+ * A BARE-major range ("1", "^1", "~1" — no explicit minor) is a
+ * MAJOR-compatibility claim: it accepts every additive (minor) bump of that
+ * major, which is what makes "^1"/"1" plugins keep working on the 1.1 ABI
+ * (an author pinning minor/patch declares it explicitly: "1.0.0", "~1.0").
*/
-export const FRONTEND_ABI_VERSION = "1";
+export const FRONTEND_ABI_VERSION = "1.1";
-/** The host version the handshake checks against (normalized to semver). */
-const HOST_VERSION = "1.0.0";
+/**
+ * The host version the handshake checks against (normalized to semver) —
+ * tracks FRONTEND_ABI_VERSION's major.minor at a 0 patch.
+ */
+const HOST_VERSION = "1.1.0";
interface SemVer {
readonly major: number;
readonly minor: number;
readonly patch: number;
+ /** The range string named the minor EXPLICITLY (a pin, not a bare major). */
+ readonly explicitMinor: boolean;
}
/** Parse a 1–3 component version ("1", "1.0", "1.0.0") — missing parts are 0. */
@@ -33,6 +44,7 @@ function parseSemver(version: string): SemVer | null {
major: Number(match[1]),
minor: Number(match[2] ?? 0),
patch: Number(match[3] ?? 0),
+ explicitMinor: match[2] !== undefined,
};
}
@@ -59,7 +71,7 @@ export function isFrontendCompatible(apiVersion: string, hostVersion = HOST_VERS
if (min === null) return false;
if (!gte(host, min)) return false;
if (min.major === 0) {
- return host.major === 0 && host.minor === min.minor;
+ return host.major === 0 && (min.explicitMinor ? host.minor === min.minor : true);
}
return host.major === min.major;
}
@@ -67,6 +79,9 @@ export function isFrontendCompatible(apiVersion: string, hostVersion = HOST_VERS
const min = parseSemver(range.slice(1));
if (min === null) return false;
if (!gte(host, min)) return false;
+ // A BARE "~1" is a major-compatibility claim (any 1.x.y); an explicit
+ // minor ("~1.0") pins the minor, as tilde always has.
+ if (!min.explicitMinor) return host.major === min.major;
return host.major === min.major && host.minor === min.minor;
}
if (range.startsWith(">=")) {
@@ -76,5 +91,9 @@ export function isFrontendCompatible(apiVersion: string, hostVersion = HOST_VERS
}
const exact = parseSemver(range);
if (exact === null) return false;
+ // A BARE major ("1") names the major only — it accepts that major's
+ // additive bumps (the "v1 plugins keep working" rule). An explicit
+ // version ("1.0", "1.0.0") pins exactly, as before.
+ if (!exact.explicitMinor) return host.major === exact.major;
return host.major === exact.major && host.minor === exact.minor && host.patch === exact.patch;
}
diff --git a/src/features/layout/adapter/abi-port.test.ts b/src/features/layout/adapter/abi-port.test.ts
new file mode 100644
index 0000000..d11bf9e
--- /dev/null
+++ b/src/features/layout/adapter/abi-port.test.ts
@@ -0,0 +1,96 @@
+import { DEFAULT_LAYOUT, type LayoutTree } from "@dispatch/layout-contract";
+import { describe, expect, it, vi } from "vitest";
+import type { LayoutProviderState, LayoutStore } from "../store.svelte";
+import { abiTree, createLayoutAbiPort } from "./abi-port";
+
+/**
+ * The ABI `layout` port tests (plan 04 §4.5) — the port is pure composition
+ * over the layout store + the ops HTTP edge, so BOTH deps are fakes (the
+ * outermost edges; nothing internal is mocked — asymmetric testing).
+ */
+
+function fakeStore(
+ overrides: Partial<Pick<LayoutStore, "tree" | "providerState" | "activeWorkspaceId">> = {},
+): LayoutStore {
+ return {
+ get tree(): LayoutTree {
+ return DEFAULT_LAYOUT;
+ },
+ get providerState(): LayoutProviderState {
+ return "present";
+ },
+ get activeWorkspaceId(): string {
+ return "ws-a";
+ },
+ probe: vi.fn(async () => {}),
+ onWorkspaceChanged: vi.fn(),
+ onServerTree: vi.fn(),
+ onTreeChange: vi.fn(),
+ resetToDefault: vi.fn(async () => null),
+ onExtensionsChanged: vi.fn(),
+ ...overrides,
+ } as LayoutStore;
+}
+
+describe("abiTree — the getTree decision (pure)", () => {
+ it("a PRESENT provider exposes the tree", () => {
+ expect(abiTree("present", DEFAULT_LAYOUT)).toBe(DEFAULT_LAYOUT);
+ });
+
+ it("no-provider mode (absent) exposes NULL — the local fallback is not shared state", () => {
+ expect(abiTree("absent", DEFAULT_LAYOUT)).toBeNull();
+ });
+
+ it("an unknown (not-yet-probed) provider exposes NULL too", () => {
+ expect(abiTree("unknown", DEFAULT_LAYOUT)).toBeNull();
+ });
+});
+
+describe("createLayoutAbiPort — applyOps → the ops route", () => {
+ it("posts to the ACTIVE workspace and feeds the returned tree back through the store", async () => {
+ const ops = [{ type: "ensurePanel", panels: "side", view: "demo" }] as const;
+ const edited: LayoutTree = { ...DEFAULT_LAYOUT };
+ const store = fakeStore();
+ const seen: string[] = [];
+ const port = createLayoutAbiPort({
+ store,
+ postOps: async (workspaceId, o) => {
+ seen.push(workspaceId);
+ return { ok: true, tree: edited, applied: o.length };
+ },
+ });
+
+ const result = await port.applyOps(ops);
+ expect(seen).toEqual(["ws-a"]); // the ACTIVE workspace at CALL time
+ expect(result).toEqual({ ok: true, tree: edited, applied: 1 });
+ // The successful apply echoes the tree into the store (instant local
+ // re-render; the WS broadcast echo is idempotent with this).
+ expect(store.onServerTree).toHaveBeenCalledWith("ws-a", edited);
+ });
+
+ it("a failed batch (typed no-provider error) does NOT touch the store", async () => {
+ const store = fakeStore({ providerState: "absent" });
+ const port = createLayoutAbiPort({
+ store,
+ postOps: async () => ({ ok: false, error: "no layout provider active" }),
+ });
+
+ const result = await port.applyOps([{ type: "removeNode", target: "side" }]);
+ expect(result).toEqual({ ok: false, error: "no layout provider active" });
+ expect(store.onServerTree).not.toHaveBeenCalled();
+ });
+
+ it("getTree returns the active tree while the provider is present, null when absent", () => {
+ const port = createLayoutAbiPort({
+ store: fakeStore({ providerState: "present" }),
+ postOps: async () => ({ ok: false, error: "unused" }),
+ });
+ expect(port.getTree()).toBe(DEFAULT_LAYOUT);
+
+ const absent = createLayoutAbiPort({
+ store: fakeStore({ providerState: "absent" }),
+ postOps: async () => ({ ok: false, error: "no layout provider active" }),
+ });
+ expect(absent.getTree()).toBeNull();
+ });
+});
diff --git a/src/features/layout/adapter/abi-port.ts b/src/features/layout/adapter/abi-port.ts
new file mode 100644
index 0000000..69576c6
--- /dev/null
+++ b/src/features/layout/adapter/abi-port.ts
@@ -0,0 +1,60 @@
+import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract";
+import type { LayoutProviderState, LayoutStore } from "../store.svelte";
+
+/**
+ * The port the frontend-plugin ABI exposes as `api.layout` (plan 04 §4.5).
+ * The composition root builds it with {@link createLayoutAbiPort} and the
+ * plugin host hands it to every `activate(api)` — plugins never see the
+ * store or the HTTP edge, only THIS.
+ */
+export interface LayoutAbiPort {
+ /**
+ * Apply op-vocabulary edits to the ACTIVE workspace's tree through the
+ * provider (`POST /workspaces/:id/layout/ops`). Atomic; the no-provider
+ * 404 maps to the typed `{ ok: false, error: "no layout provider active" }`.
+ */
+ readonly applyOps: (ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>;
+ /** The ACTIVE workspace's tree; `null` in no-provider mode. */
+ readonly getTree: () => LayoutTree | null;
+}
+
+export interface LayoutAbiPortDeps {
+ /** The layout store (the active workspace's tree + provider state). */
+ readonly store: LayoutStore;
+ /** The ops HTTP edge — bound per CALL to the then-active workspace. */
+ readonly postOps: (workspaceId: string, ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>;
+}
+
+/**
+ * Build the ABI's `layout` port over the layout store + the ops HTTP edge
+ * (pure composition — no ambient state; both deps injected). A successful
+ * apply feeds the returned tree back through `onServerTree` so the local
+ * client re-renders immediately (the server's WS broadcast is the
+ * authoritative echo for every client, this one included — applying it twice
+ * is idempotent). The workspace is read at CALL time, so a workspace switch
+ * between calls targets the right tree; a switch mid-flight is guarded by
+ * `onServerTree`'s own workspace filter.
+ */
+export function createLayoutAbiPort(deps: LayoutAbiPortDeps): LayoutAbiPort {
+ return {
+ async applyOps(ops): Promise<ApplyOpsResult> {
+ const workspaceId = deps.store.activeWorkspaceId;
+ const result = await deps.postOps(workspaceId, ops);
+ if (result.ok) {
+ deps.store.onServerTree(workspaceId, result.tree);
+ }
+ return result;
+ },
+ getTree(): LayoutTree | null {
+ // `present` = an active provider is serving the active workspace. In
+ // no-provider (and not-yet-probed) mode the local tree is a fallback,
+ // not shared state — null tells the plugin there is nothing to read.
+ return abiTree(deps.store.providerState, deps.store.tree);
+ },
+ };
+}
+
+/** The getTree decision, pure: only a PRESENT provider's tree is exposed. */
+export function abiTree(state: LayoutProviderState, tree: LayoutTree): LayoutTree | null {
+ return state === "present" ? tree : null;
+}
diff --git a/src/features/layout/adapter/http.test.ts b/src/features/layout/adapter/http.test.ts
index 1801ca6..cc7d7e9 100644
--- a/src/features/layout/adapter/http.test.ts
+++ b/src/features/layout/adapter/http.test.ts
@@ -135,3 +135,48 @@ describe("createLayoutHttp — deleteTree", () => {
expect(result).toMatchObject({ ok: false, notFound: true });
});
});
+
+describe("createLayoutHttp — postOps (plan 04 §4.2)", () => {
+ it("POSTs { ops } to the ops route and unwraps { tree, applied }", async () => {
+ const capture: { url?: string; init?: RequestInit } = {};
+ const ops = [{ type: "ensurePanel", panels: "side", view: "demo" }] as const;
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(200, { tree: DEFAULT_LAYOUT, applied: 1 }, capture),
+ );
+ const result = await http.postOps("ws-a", ops);
+ expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT, applied: 1 });
+ expect(capture.url).toBe(`${LAYOUT_URL}/ops`);
+ expect(capture.init?.method).toBe("POST");
+ expect(capture.init?.body).toBe(JSON.stringify({ ops }));
+ });
+
+ it("maps a 404 to the typed no-provider error (the ABI's documented failure)", async () => {
+ const http = createLayoutHttp("http://backend.test", fetchReturning(404, {}));
+ const result = await http.postOps("ws-a", []);
+ expect(result).toEqual({ ok: false, error: "no layout provider active" });
+ });
+
+ it("maps a 400 { error, failedOp } to the atomic-reject failure", async () => {
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(400, { error: "op 1: no such node", failedOp: 1 }),
+ );
+ const result = await http.postOps("ws-a", [{ type: "removeNode", target: "x" }]);
+ expect(result).toEqual({ ok: false, error: "op 1: no such node", failedOp: 1 });
+ });
+
+ it("a malformed 200 (no tree in the body) is a failure, never a silent ok", async () => {
+ const http = createLayoutHttp("http://backend.test", fetchReturning(200, { applied: 2 }));
+ const result = await http.postOps("ws-a", []);
+ expect(result).toMatchObject({ ok: false });
+ });
+
+ it("a network failure degrades to a typed failure (never throws)", async () => {
+ const http = createLayoutHttp("http://backend.test", async () => {
+ throw new TypeError("fetch failed");
+ });
+ const result = await http.postOps("ws-a", []);
+ expect(result).toEqual({ ok: false, error: "fetch failed" });
+ });
+});
diff --git a/src/features/layout/adapter/http.ts b/src/features/layout/adapter/http.ts
index 2f91e43..ff15a70 100644
--- a/src/features/layout/adapter/http.ts
+++ b/src/features/layout/adapter/http.ts
@@ -1,11 +1,15 @@
-import type { LayoutTree } from "@dispatch/layout-contract";
+import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract";
/**
* Layout HTTP effects (plan 02 §2.6) — the injected edge that talks to the
* ACTIVE layout provider's well-known routes:
* - `GET /workspaces/:id/layout` → `{ tree }` (read seeds a fresh workspace)
* - `PUT /workspaces/:id/layout` body `{ tree }` → 200 `{ tree }` / 400 `{ error }`
- * - `DELETE /workspaces/:id/layout` → the fresh seed (reset; plan 04 adds the UI)
+ * - `DELETE /workspaces/:id/layout` → the fresh seed (reset; the plugins
+ * modal's "Reset layout to default" button — plan 04 §4.4)
+ * - `POST /workspaces/:id/layout/ops` body `{ ops }` → 200 `{ tree, applied }`
+ * / 400 `{ error, failedOp? }` / 404 no provider (plan 04 §4.2 — the FE
+ * ABI's `layout.applyOps` door)
*
* Mirrors the plugins adapter's pattern: `httpBase` + an injected `fetchImpl`
* (testable without the network). The 404 is a SIGNAL, not an error: it means
@@ -22,6 +26,13 @@ export interface LayoutHttp {
getTree(workspaceId: string): Promise<LayoutHttpResult>;
putTree(workspaceId: string, tree: LayoutTree): Promise<LayoutHttpResult>;
deleteTree(workspaceId: string): Promise<LayoutHttpResult>;
+ /**
+ * The plan-04 ops door (§4.2): apply op-vocabulary edits through the
+ * provider. The result IS the contract's `ApplyOpsResult` — the 404
+ * (no provider) maps to the typed `{ ok: false, error: "no layout
+ * provider active" }` failure the ABI documents.
+ */
+ postOps(workspaceId: string, ops: readonly LayoutOp[]): Promise<ApplyOpsResult>;
}
/** Read an error message from a non-2xx body (`{ error }`), tolerating junk. */
@@ -113,5 +124,52 @@ export function createLayoutHttp(httpBase: string, fetchImpl: typeof fetch): Lay
};
}
},
+
+ async postOps(workspaceId, ops): Promise<ApplyOpsResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout/ops`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ops }),
+ },
+ );
+ // The no-provider 404 is the ABI's typed signal (plan 04 §4.5).
+ if (res.status === 404) {
+ return { ok: false, error: "no layout provider active" };
+ }
+ if (!res.ok) return await opsError(res);
+ const body = (await res.json()) as { tree?: unknown; applied?: unknown };
+ if (body === null || typeof body !== "object" || body.tree === undefined)
+ return { ok: false, error: "Malformed layout response" };
+ return {
+ ok: true,
+ tree: body.tree as LayoutTree,
+ applied: typeof body.applied === "number" ? body.applied : ops.length,
+ };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Layout request failed",
+ };
+ }
+ },
};
}
+
+/** Read an ops-failure body (`{ error, failedOp? }`), tolerating junk. */
+async function opsError(res: Response): Promise<ApplyOpsResult> {
+ try {
+ const body = (await res.json()) as { error?: unknown; failedOp?: unknown };
+ const error =
+ typeof body.error === "string" && body.error.length > 0 ? body.error : `HTTP ${res.status}`;
+ return {
+ ok: false,
+ error,
+ ...(typeof body.failedOp === "number" ? { failedOp: body.failedOp } : {}),
+ };
+ } catch {
+ return { ok: false, error: `HTTP ${res.status}` };
+ }
+}
diff --git a/src/features/layout/index.ts b/src/features/layout/index.ts
index 6a17589..8a464fe 100644
--- a/src/features/layout/index.ts
+++ b/src/features/layout/index.ts
@@ -7,6 +7,12 @@
* OUTSIDE the tree — the tree owns the content region.
*/
+export {
+ abiTree,
+ createLayoutAbiPort,
+ type LayoutAbiPort,
+ type LayoutAbiPortDeps,
+} from "./adapter/abi-port";
export { createLayoutHttp, type LayoutHttp, type LayoutHttpResult } from "./adapter/http";
export {
bootDefaultOpen,
diff --git a/src/features/layout/store.svelte.ts b/src/features/layout/store.svelte.ts
index 3d7de3f..5fdf100 100644
--- a/src/features/layout/store.svelte.ts
+++ b/src/features/layout/store.svelte.ts
@@ -54,6 +54,16 @@ export interface LayoutStore {
onServerTree(workspaceId: string, tree: LayoutTree): void;
/** A user edit (the panels' dropdowns / TileRoot ops). Persists via PUT. */
onTreeChange(next: LayoutTree): void;
+ /**
+ * Reset the workspace's layout to the default seed (plan 04 §4.4 — the
+ * plugins modal's "Reset layout to default" affordance:
+ * `DELETE /workspaces/:id/layout` → re-seed → broadcast). Resolves to
+ * `null` on success (the fresh seed is applied locally + pushed via WS to
+ * every client) or to the FAILURE REASON — the no-provider 404 resolves
+ * to the user-facing "no layout plugin enabled" (the button's gate renders
+ * the same reason before this runs).
+ */
+ resetToDefault(workspaceId: string): Promise<string | null>;
/** Multi-client coherence (§11): re-probe on `extensions-changed`. */
onExtensionsChanged(): void;
}
@@ -260,6 +270,21 @@ export function createLayoutStore(opts: LayoutStoreOptions): LayoutStore {
}, debounceMs);
},
+ async resetToDefault(workspaceId): Promise<string | null> {
+ const result = await http.deleteTree(workspaceId);
+ if (result.ok) {
+ // The server re-seeded and broadcast (every client resets); this
+ // client applies the fresh seed immediately too (idempotent with the
+ // WS echo). Only the ACTIVE workspace's tree is ours to update.
+ notePresent();
+ if (workspaceId === activeWorkspaceId) applyServerTree(result.tree);
+ return null;
+ }
+ // The no-provider 404: the user-facing reason the plan specifies.
+ if (result.notFound) return "no layout plugin enabled";
+ return result.error;
+ },
+
onExtensionsChanged() {
void runProbe(activeWorkspaceId, false).catch(() => {
// runProbe never throws for HTTP failures; this guard is for the
diff --git a/src/features/layout/store.test.ts b/src/features/layout/store.test.ts
index 1d4cdf0..73850ed 100644
--- a/src/features/layout/store.test.ts
+++ b/src/features/layout/store.test.ts
@@ -1,4 +1,9 @@
-import { DEFAULT_LAYOUT, type LayoutTree } from "@dispatch/layout-contract";
+import {
+ type ApplyOpsResult,
+ DEFAULT_LAYOUT,
+ type LayoutOp,
+ type LayoutTree,
+} from "@dispatch/layout-contract";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { LayoutHttp, LayoutHttpResult } from "./adapter/http";
import { overlaySidePanels } from "./logic/seed";
@@ -15,8 +20,11 @@ import { createLayoutStore } from "./store.svelte";
interface HttpFake extends LayoutHttp {
getCalls: string[];
putCalls: Array<{ workspaceId: string; tree: LayoutTree }>;
+ deleteCalls: string[];
getResult: (workspaceId: string) => LayoutHttpResult;
putResult: (workspaceId: string, tree: LayoutTree) => LayoutHttpResult;
+ deleteResult: (workspaceId: string) => LayoutHttpResult;
+ opsResult: (workspaceId: string, ops: readonly LayoutOp[]) => ApplyOpsResult;
}
function fakeHttp(
@@ -32,11 +40,18 @@ function fakeHttp(
): HttpFake {
const getCalls: string[] = [];
const putCalls: Array<{ workspaceId: string; tree: LayoutTree }> = [];
+ const deleteCalls: string[] = [];
const fake: HttpFake = {
getCalls,
putCalls,
+ deleteCalls,
getResult,
putResult,
+ // Re-scriptable outcome (default: the fresh seed a reset returns).
+ deleteResult: () => ({ ok: true, tree: DEFAULT_LAYOUT }),
+ // Re-scriptable outcome (default: the typed no-provider failure the ABI
+ // documents; tests re-assign mid-test like putResult).
+ opsResult: () => ({ ok: false, error: "no layout provider active" }),
// Delegate to the CURRENT properties so a test can re-script outcomes
// mid-test (http.putResult = …).
async getTree(workspaceId) {
@@ -47,8 +62,12 @@ function fakeHttp(
putCalls.push({ workspaceId, tree });
return fake.putResult(workspaceId, tree);
},
- async deleteTree() {
- return { ok: false, notFound: true, error: "unused" };
+ async deleteTree(workspaceId) {
+ deleteCalls.push(workspaceId);
+ return fake.deleteResult(workspaceId);
+ },
+ async postOps(workspaceId, ops) {
+ return fake.opsResult(workspaceId, ops);
},
};
return fake;
@@ -424,3 +443,49 @@ describe("layout store — one-way localStorage migration (§2.7)", () => {
]);
});
});
+
+describe("layout store — resetToDefault (plan 04 §4.4, the reset affordance)", () => {
+ it("DELETEs the workspace's layout, applies the fresh seed, and resolves null", async () => {
+ const custom = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp();
+ // The DELETE returns the FRESH SEED (the server re-seeded).
+ http.deleteResult = () => ({ ok: true, tree: DEFAULT_LAYOUT });
+ const store = makeStore(http);
+
+ // Get to a NON-default tree first: a present provider + a pushed edit.
+ http.getResult = () => ({ ok: true, tree: custom });
+ await store.probe("ws-a");
+ expect(store.tree).toEqual(custom);
+
+ const failure = await store.resetToDefault("ws-a");
+ expect(failure).toBeNull();
+ expect(http.deleteCalls).toEqual(["ws-a"]);
+ // The fresh seed is applied locally (the WS broadcast echo is idempotent).
+ expect(store.tree).toEqual(DEFAULT_LAYOUT);
+ expect(store.providerState).toBe("present");
+ });
+
+ it("a 404 (no provider) resolves to the user-facing 'no layout plugin enabled' reason", async () => {
+ const http = fakeHttp();
+ http.deleteResult = () => ({
+ ok: false,
+ notFound: true,
+ error: "No layout provider is active",
+ });
+ const store = makeStore(http);
+
+ const failure = await store.resetToDefault("ws-a");
+ expect(failure).toBe("no layout plugin enabled");
+ expect(store.tree).toEqual(DEFAULT_LAYOUT); // untouched local default
+ });
+
+ it("another failure (5xx on a present provider) resolves to its error message", async () => {
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ http.deleteResult = () => ({ ok: false, notFound: false, error: "storage write failed" });
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ const failure = await store.resetToDefault("ws-a");
+ expect(failure).toBe("storage write failed");
+ });
+});
diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts
index 7a9d1a0..f79ed98 100644
--- a/src/features/plugins/index.ts
+++ b/src/features/plugins/index.ts
@@ -13,6 +13,7 @@ export { autoCheckBackendPrereqs } from "./logic/prereqs";
export type {
PluginNode,
PluginSection,
+ WorkspaceLayoutPort,
} from "./logic/types";
export { TIER_ORDER, tierLabel } from "./logic/types";
export { groupByTier, pluginNodes, splitSections, withFrontendStates } from "./logic/view-model";
diff --git a/src/features/plugins/logic/types.ts b/src/features/plugins/logic/types.ts
index d5fa4bb..ec80c9b 100644
--- a/src/features/plugins/logic/types.ts
+++ b/src/features/plugins/logic/types.ts
@@ -51,6 +51,28 @@ export function tierLabel(tier: PluginTier): string {
export const TIER_ORDER: readonly PluginTier[] = ["core", "providers", "standard"];
/**
+ * The layout port (plan 04 §4.4) the workspace plugins modal needs for the
+ * "Reset layout to default" affordance — the modal is the natural home (it
+ * is where users manage the layout plugins themselves). Wired by the
+ * composition root over the layout store; absent → the section doesn't
+ * render (tests).
+ */
+export interface WorkspaceLayoutPort {
+ /**
+ * Whether a layout provider is active (the reset button's gate). False →
+ * the button is DISABLED with the reason shown ("no layout plugin
+ * enabled") — there is nothing to reset and no persistence to re-seed.
+ */
+ readonly providerActive: boolean;
+ /**
+ * Reset the workspace's layout to the default seed (`DELETE
+ * /workspaces/:id/layout` → re-seed → broadcast). Resolves to `null` on
+ * success or to the failure reason to display.
+ */
+ readonly reset: (workspaceId: string) => Promise<string | null>;
+}
+
+/**
* Runtime-normalize a tier value to the canonical THREE: anything that is not
* exactly `core` | `providers` | `standard` — a missing tier, or a value
* outside the contract ("kernel" is NOT a tier; the kernel hosts plugins, it
diff --git a/src/features/plugins/ui/ResetLayoutConfirm.svelte b/src/features/plugins/ui/ResetLayoutConfirm.svelte
new file mode 100644
index 0000000..1f26601
--- /dev/null
+++ b/src/features/plugins/ui/ResetLayoutConfirm.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ /**
+ * The "Reset layout to default?" confirm (plan 04 §4.4) — the dialog copy
+ * names the consequence exactly: this workspace's layout returns to the
+ * default; plugin and dropdown customizations are lost (there is NO
+ * undo — the no-revert semantics make the reset the recovery path).
+ */
+ let {
+ onConfirm,
+ onCancel,
+ }: {
+ onConfirm: () => void;
+ onCancel: () => void;
+ } = $props();
+</script>
+
+<div class="modal modal-open" role="dialog" aria-label="Reset layout to default?">
+ <div class="modal-box max-w-md">
+ <h3 class="text-lg font-bold text-warning">Reset layout to default?</h3>
+ <p class="py-3 text-sm">
+ This workspace's layout returns to the default; plugin and dropdown customizations are lost.
+ </p>
+ <div class="modal-action">
+ <button type="button" class="btn btn-ghost btn-sm" onclick={onCancel}>Cancel</button>
+ <button type="button" class="btn btn-warning btn-sm" onclick={onConfirm}>Reset</button>
+ </div>
+ </div>
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
+ <div class="modal-backdrop" onclick={onCancel}></div>
+</div>
diff --git a/src/features/plugins/ui/WorkspacePluginModal.svelte b/src/features/plugins/ui/WorkspacePluginModal.svelte
index bce885b..d1bb27a 100644
--- a/src/features/plugins/ui/WorkspacePluginModal.svelte
+++ b/src/features/plugins/ui/WorkspacePluginModal.svelte
@@ -2,17 +2,25 @@
import { onMount } from "svelte";
import type { PluginsStore } from "../store.svelte";
import { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "../logic/workspace-row";
+ import type { WorkspaceLayoutPort } from "../logic/types";
+ import ResetLayoutConfirm from "./ResetLayoutConfirm.svelte";
let {
workspaceId,
workspaceTitle,
plugins,
+ layout,
onClose,
}: {
workspaceId: string;
/** The workspace's display title (modal heading). */
workspaceTitle: string;
plugins: PluginsStore;
+ /**
+ * The layout port (plan 04 §4.4) — gates + powers the "Reset layout to
+ * default" affordance. Absent → the section doesn't render (tests).
+ */
+ layout?: WorkspaceLayoutPort;
onClose: () => void;
} = $props();
@@ -31,6 +39,21 @@
async function toggle(pluginId: string, checked: boolean): Promise<void> {
await plugins.setWorkspaceState(workspaceId, pluginId, checked ? "enabled" : "disabled");
}
+
+ // ── The reset affordance (plan 04 §4.4) ─────────────────────────────────
+ let resetOpen = $state(false);
+ let resetting = $state(false);
+ let resetError = $state<string | null>(null);
+
+ async function confirmReset(): Promise<void> {
+ if (layout === undefined || resetting) return;
+ resetting = true;
+ resetError = null;
+ const failure = await layout.reset(workspaceId);
+ resetting = false;
+ resetOpen = false;
+ if (failure !== null) resetError = failure;
+ }
</script>
<div class="modal modal-open" role="dialog" aria-label={`Plugins for ${workspaceTitle}`}>
@@ -101,6 +124,35 @@
</table>
{/if}
+ {#if layout}
+ <div class="mt-3 border-t border-base-300 pt-3">
+ <div class="flex items-center justify-between gap-3">
+ <div>
+ <p class="text-sm font-medium">Layout</p>
+ {#if layout.providerActive}
+ <p class="text-xs opacity-60">Managed by the active layout plugin.</p>
+ {:else}
+ <p class="text-xs opacity-60">No layout plugin enabled — no layout to reset.</p>
+ {/if}
+ </div>
+ <button
+ type="button"
+ class="btn btn-warning btn-sm"
+ disabled={!layout.providerActive || resetting}
+ title={layout.providerActive
+ ? "Restore this workspace's default layout"
+ : "No layout plugin enabled"}
+ onclick={() => (resetOpen = true)}
+ >
+ Reset layout to default
+ </button>
+ </div>
+ {#if resetError}
+ <p class="mt-1 text-xs text-error" role="alert">Resetting the layout failed: {resetError}</p>
+ {/if}
+ </div>
+ {/if}
+
<div class="modal-action">
<button type="button" class="btn btn-sm" onclick={onClose}>Close</button>
</div>
@@ -108,3 +160,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={onClose}></div>
</div>
+
+{#if resetOpen && layout}
+ <ResetLayoutConfirm onConfirm={() => void confirmReset()} onCancel={() => (resetOpen = false)} />
+{/if}
diff --git a/src/features/plugins/ui/WorkspacePluginModal.test.ts b/src/features/plugins/ui/WorkspacePluginModal.test.ts
index 32e88d7..6bc7179 100644
--- a/src/features/plugins/ui/WorkspacePluginModal.test.ts
+++ b/src/features/plugins/ui/WorkspacePluginModal.test.ts
@@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { PluginsHttp } from "../adapter/http";
+import type { WorkspaceLayoutPort } from "../logic/types";
import { createPluginsStore } from "../store.svelte";
import WorkspacePluginModal from "./WorkspacePluginModal.svelte";
@@ -46,6 +47,14 @@ async function renderModal(http: PluginsHttp = fakeHttp()) {
return { store, http };
}
+/** A layout port (plan 04 §4.4) scripted per test. */
+function fakeLayoutPort(
+ providerActive = true,
+ reset: WorkspaceLayoutPort["reset"] = vi.fn(async () => null),
+): WorkspaceLayoutPort {
+ return { providerActive, reset };
+}
+
describe("WorkspacePluginModal", () => {
it("renders the workspace's plugin rows", async () => {
await renderModal();
@@ -122,3 +131,75 @@ describe("WorkspacePluginModal", () => {
expect(screen.getByText(/other workspaces inherit these states/i)).toBeInTheDocument();
});
});
+
+describe("WorkspacePluginModal — the reset-layout affordance (plan 04 §4.4)", () => {
+ async function renderWithLayout(layout?: ReturnType<typeof fakeLayoutPort>) {
+ const store = createPluginsStore(fakeHttp());
+ await store.openWorkspace("ws");
+ render(WorkspacePluginModal, {
+ props: {
+ workspaceId: "ws",
+ workspaceTitle: "My Workspace",
+ plugins: store,
+ ...(layout ? { layout } : {}),
+ onClose: vi.fn(),
+ },
+ });
+ return layout;
+ }
+
+ it("renders an ENABLED reset button when a layout provider is active", async () => {
+ await renderWithLayout(fakeLayoutPort(true));
+ const button = screen.getByRole("button", { name: /reset layout to default/i });
+ expect(button).toBeEnabled();
+ });
+
+ it("the button is DISABLED with the 'no layout plugin enabled' reason when no provider is active", async () => {
+ await renderWithLayout(fakeLayoutPort(false));
+ const button = screen.getByRole("button", { name: /reset layout to default/i });
+ expect(button).toBeDisabled();
+ expect(button.getAttribute("title")).toMatch(/no layout plugin enabled/i);
+ expect(screen.getByText(/no layout plugin enabled/i)).toBeInTheDocument();
+ });
+
+ it("no layout port → the layout section doesn't render at all (tests / no wiring)", async () => {
+ await renderWithLayout(undefined);
+ expect(screen.queryByRole("button", { name: /reset layout to default/i })).toBeNull();
+ });
+
+ it("confirming the dialog resets the layout (and the dialog copy names the consequence)", async () => {
+ const user = userEvent.setup();
+ const reset = vi.fn(async () => null);
+ await renderWithLayout(fakeLayoutPort(true, reset));
+
+ await user.click(screen.getByRole("button", { name: /reset layout to default/i }));
+ // The confirm dialog names the consequence exactly (§4.4 copy).
+ expect(
+ screen.getByText(
+ /layout returns to the default; plugin and dropdown customizations are lost/i,
+ ),
+ ).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: /^reset$/i }));
+ expect(reset).toHaveBeenCalledWith("ws");
+ });
+
+ it("cancelling the dialog never resets", async () => {
+ const user = userEvent.setup();
+ const reset = vi.fn(async () => null);
+ await renderWithLayout(fakeLayoutPort(true, reset));
+
+ await user.click(screen.getByRole("button", { name: /reset layout to default/i }));
+ await user.click(screen.getByRole("button", { name: /cancel/i }));
+ expect(reset).not.toHaveBeenCalled();
+ });
+
+ it("a failed reset surfaces the reason as an alert", async () => {
+ const user = userEvent.setup();
+ const reset = vi.fn(async () => "no layout plugin enabled");
+ await renderWithLayout(fakeLayoutPort(true, reset));
+
+ await user.click(screen.getByRole("button", { name: /reset layout to default/i }));
+ await user.click(screen.getByRole("button", { name: /^reset$/i }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(/resetting the layout failed/i);
+ });
+});
diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte
index 70cfda2..8c004aa 100644
--- a/src/features/workspaces/ui/WorkspaceCard.svelte
+++ b/src/features/workspaces/ui/WorkspaceCard.svelte
@@ -1,7 +1,7 @@
<script lang="ts">
import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire";
import { untrack } from "svelte";
- import { WorkspacePluginModal, type PluginsStore } from "../../plugins";
+ import { WorkspacePluginModal, type PluginsStore, type WorkspaceLayoutPort } from "../../plugins";
import type { WorkspaceStore } from "../store.svelte";
import { relativeTime } from "../logic/view-model";
import { workspacePath } from "../logic/route";
@@ -14,6 +14,7 @@
computers,
hasActive,
plugins,
+ layout,
}: {
ws: WorkspaceEntry;
store: WorkspaceStore;
@@ -33,6 +34,12 @@
* (tests) → no button.
*/
plugins?: PluginsStore;
+ /**
+ * Optional layout port (plan 04 §4.4): forwarded to the plugins modal
+ * for the "Reset layout to default" affordance. Absent → the modal's
+ * layout section doesn't render.
+ */
+ layout?: WorkspaceLayoutPort;
} = $props();
// Whether at least one conversation in this workspace is currently active
@@ -306,6 +313,7 @@
workspaceId={ws.id}
workspaceTitle={ws.title}
{plugins}
+ {...(layout ? { layout } : {})}
onClose={() => (pluginsOpen = false)}
/>
{/if}
diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte
index 548d733..5a4d88f 100644
--- a/src/features/workspaces/ui/WorkspacesHome.svelte
+++ b/src/features/workspaces/ui/WorkspacesHome.svelte
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import type { ComputerEntry } from "@dispatch/wire";
- import { PluginManager, type PluginsStore } from "../../plugins";
+ import { PluginManager, type PluginsStore, type WorkspaceLayoutPort } from "../../plugins";
import type { WorkspaceStore } from "../store.svelte";
import { isValidSlug, workspacePath } from "../logic/route";
import WorkspaceCard from "./WorkspaceCard.svelte";
@@ -12,6 +12,7 @@
computers,
hasActive,
plugins,
+ layout,
}: {
store: WorkspaceStore;
onNavigate: (path: string) => void;
@@ -29,6 +30,12 @@
* plugins) → the buttons don't render.
*/
plugins?: PluginsStore;
+ /**
+ * The layout port (plan 04 §4.4): forwarded to each {@link WorkspaceCard}
+ * for the plugins modal's "Reset layout to default" affordance. Absent →
+ * the modal's layout section doesn't render.
+ */
+ layout?: WorkspaceLayoutPort;
} = $props();
onMount(() => {
@@ -128,6 +135,7 @@
{computers}
{...(hasActive ? { hasActive } : {})}
{...(plugins ? { plugins } : {})}
+ {...(layout ? { layout } : {})}
/>
{/each}
</ul>