diff options
| author | Adam Malczewski <[email protected]> | 2026-09-05 17:33:06 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-05 17:33:06 +0900 |
| commit | 254f90a4081a3f15dfe54277da922579dbaee254 (patch) | |
| tree | f5f4e409196109baf938cd9d557e244242df18ce /src | |
| parent | 535215bfd1fee6087613108f42d28cd2807184d3 (diff) | |
| download | dispatch-web-254f90a4081a3f15dfe54277da922579dbaee254.tar.gz dispatch-web-254f90a4081a3f15dfe54277da922579dbaee254.zip | |
feat(layout): plan 04 — ABI layout.applyOps + reset affordance
Diffstat (limited to 'src')
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> |
