From 985116fdc8961e656cd9d3027debcd1a6237d6c2 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 5 Sep 2026 01:20:28 +0900 Subject: feat(layout): plan 02 — provider consumption + layout WS + localStorage migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dispatch/transport-contract.reference.md | 11 + backend-handoff.md | 56 ++++ src/adapters/ws/index.ts | 8 + src/adapters/ws/logic.test.ts | 35 +++ src/adapters/ws/logic.ts | 26 ++ src/app/App.svelte | 39 +-- src/app/App.test.ts | 57 +++- src/app/store.svelte.ts | 45 +++ src/core/wire/conformance.ts | 2 + src/features/layout/adapter/http.test.ts | 136 +++++++++ src/features/layout/adapter/http.ts | 117 ++++++++ src/features/layout/index.ts | 3 + src/features/layout/logic/migration.test.ts | 72 +++++ src/features/layout/logic/migration.ts | 76 +++++ src/features/layout/store.svelte.ts | 270 ++++++++++++++++++ src/features/layout/store.test.ts | 420 ++++++++++++++++++++++++++++ 16 files changed, 1337 insertions(+), 36 deletions(-) create mode 100644 src/features/layout/adapter/http.test.ts create mode 100644 src/features/layout/adapter/http.ts create mode 100644 src/features/layout/logic/migration.test.ts create mode 100644 src/features/layout/logic/migration.ts create mode 100644 src/features/layout/store.svelte.ts create mode 100644 src/features/layout/store.test.ts diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md index f220327..304bd0b 100644 --- a/.dispatch/transport-contract.reference.md +++ b/.dispatch/transport-contract.reference.md @@ -8,6 +8,17 @@ > **Orchestrator:** SNAPSHOT of `transport-contract@0.24.0` (MCP status + computers + provider concurrency + cancel queued message). Regenerate whenever > it changes. > +> **2026-09-04 delta (tiled layout plan 02 — ADDITIVE, no version bump):** the layout-provider contract ships. NEW +> WS message: `LayoutChangedMessage` (`{ type: "layout"; workspaceId: string; tree: LayoutTree }` — server→client +> broadcast after every successful layout write; the FULL tree, decision 7; a global fan-out and CLIENTS FILTER by +> their active workspace). `LayoutTree` is re-exported from `@dispatch/layout-contract`. NEW HTTP routes (owned by the +> ACTIVE layout provider — the bundled standard `layout-chat` plugin — consumed reactively; while NO provider is active +> every route 404s, the frontend's no-provider signal): `GET /workspaces/:id/layout` (`{ tree }` — read seeds a fresh +> workspace), `PUT /workspaces/:id/layout` (body `{ tree }` → 200 `{ tree }` / 400 `{ error }` structural-invalidity), +> `DELETE /workspaces/:id/layout` (re-seed on next read; returns the fresh seed). The FE consumes these via +> `features/layout` (`adapter/http.ts` + `store.svelte.ts`); the `layout` message is parsed in `adapters/ws/logic.ts` +> and dispatched to `store.layout.onServerTree` (non-active workspaces ignored). +> > **2026-09-03 delta (plugin system — plans 05/06, NO version bump verified against the worktree):** the > plugin-manager + per-workspace enablement contract ships. NEW WS messages: `WorkspaceSetMessage` > (`{ type: "workspace.set"; workspaceId }` — client→server; the server records the connection's workspace diff --git a/backend-handoff.md b/backend-handoff.md index a67fac9..3c71bc3 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -5,6 +5,62 @@ > Per AGENTS.md "Backend seam": FE contract changes are edited + consumers updated here; BACKEND > contract changes are REPORTED UP here, and on the new version the FE re-pins the `file:` dep, > re-mirrors `.dispatch/*.reference.md`, and updates consumers. NEVER edit the backend repo. +> +> **NOTE (tiled-layout worktree, 2026-09-04):** plans 01–04 of the layout series are being +> implemented in BOTH repos of this worktree by one task (branch `feature/tiled-layout`), so the +> "never edit the backend repo" rule is superseded by that task's scope — the backend contract +> changes below were made in the same branch and the FE consumes them via the `file:` deps +> (dists rebuilt). Listed here for the record. + +## Milestone: Layout plan 02 — provider consumption + layout WS + localStorage migration + +**Slice status:** COMPLETE + committed (both repos, `feature/tiled-layout`). + +### What the FE built +- `features/layout/adapter/http.ts` — `createLayoutHttp(httpBase, fetchImpl)`: typed + `GET/PUT/DELETE /workspaces/:id/layout` with the 404-as-signal mapping (`notFound`) + + malformed-200 degrade; tested against a scripted fetch edge. +- `features/layout/logic/migration.ts` — the §2.7 one-way best-effort migration decision + (`migrationTree`/`storedPanelViews`/`sidePanelViews`): import the stored + `dispatch.sidebar.views` selection ONLY when the provider's tree is the untouched + DEFAULT_LAYOUT seed; the key is then never read again (bytes stay). +- `features/layout/store.svelte.ts` — `createLayoutStore(deps)`: owns the ACTIVE workspace's + tree; probes on boot / workspace switch / `extensions-changed` (§2.6); WS `layout` push via + `onServerTree` (non-active workspaces ignored); edits PUT the WHOLE tree lightly debounced + (300ms) with rollback to the last server tree on failure (a PUT 404 flips to no-provider mode + and KEEPS the edit — ephemeral, no rollback, no warning beyond the once-per-absence log); + collapse state NOT touched (`dispatch.sidebar.open` unchanged). +- `adapters/ws/logic.ts` parses `layout` (`{ type, workspaceId, tree }`, light shape checks); + `adapters/ws/index.ts` dispatches via `onLayout`; `core/wire/conformance.ts` exhaustive + guard extended. +- `app/store.svelte.ts` — the app store owns the `LayoutStore` instance + exposes + `store.layout`; wires `onLayout` → `layoutStore.onServerTree`, the boot probe + (`{ boot: true }` arms the §2.7 migration), `onWorkspaceChanged` in `setActiveWorkspace`, and + a re-probe in `onExtensionsChanged`. +- `app/App.svelte` — the tree is now `store.layout.tree` (was: local `$state` seeded from + localStorage); edits flow to `layout.onTreeChange`; the `dispatch.sidebar.views` READ (boot + seed) and WRITE (edit write-back) are RETIRED — its only reader is the one-way migration. +- **App tests updated:** the tests that pre-seeded `dispatch.sidebar.views` to make a sidebar + view visible now drive the layout through the provider edge (`fakeFetchWithLayout([...])` + serving `GET /workspaces/:id/layout`); the Extensions-view test is async (the tree arrives + via the provider GET). + +### Pinned contract versions + mirrors +- `@dispatch/transport-contract` (worktree dist rebuilt): consumed with the plan-02 types + (`LayoutChangedMessage` + the `layout` union member; `LayoutTree` re-exported from + `@dispatch/layout-contract`). Mirror `.dispatch/transport-contract.reference.md` updated with + a **2026-09-04 delta note**. + +### Findings / raises for the backend +1. **Outward events emitter is a noop in the composition root** (host-bin `createNoopEvents`): + the plan-02 WS push therefore follows the LIVE `extensions-changed` path instead — a typed + event hook (`layoutChanged` in `@dispatch/layout-provider-contract`) that transport-ws + subscribes to and broadcasts verbatim. If a generic `host.events` → WS bridge ever ships, + the kit's `emitChange` dep is the single wiring point to switch. +2. **§2.4 double-provide semantics VERIFIED from kernel source** (`kernel/src/bus/bus.ts`): + `provideService` on an already-provided handle THROWS ("Only one provider per handle is + allowed") → first-provider-wins; the host converts a throwing `activate()` into the + disabled-with-reason grammar. No kit wrapper needed (the plan's conditional). ## Milestone: Plugin Manager UI — FE scope (plan 07, feature/plugin-system) diff --git a/src/adapters/ws/index.ts b/src/adapters/ws/index.ts index 3a6a97d..3713c8c 100644 --- a/src/adapters/ws/index.ts +++ b/src/adapters/ws/index.ts @@ -5,6 +5,7 @@ import type { ConversationOpenMessage, ConversationStatusChangedMessage, ExtensionsChangedMessage, + LayoutChangedMessage, WsClientMessage, } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; @@ -34,6 +35,11 @@ export interface SurfaceSocketOptions { * workspace-filtered surface catalog itself). */ onExtensionsChanged?: (msg: ExtensionsChangedMessage) => void; + /** + * Broadcast after every successful layout write (plan 02 §2.5): the FULL + * tree for one workspace. The consumer filters by its active workspace. + */ + onLayout?: (msg: LayoutChangedMessage) => void; onReopen?: () => void; socketFactory?: (url: string) => WebSocketLike; } @@ -84,6 +90,8 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa opts.onConversationCompacted?.(msg as ConversationCompactedMessage); } else if (msg.type === "extensions-changed") { opts.onExtensionsChanged?.(msg as ExtensionsChangedMessage); + } else if (msg.type === "layout") { + opts.onLayout?.(msg as LayoutChangedMessage); } else { opts.onMessage(msg as SurfaceServerMessage); } diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts index c4ec7e3..a7d30a7 100644 --- a/src/adapters/ws/logic.test.ts +++ b/src/adapters/ws/logic.test.ts @@ -463,3 +463,38 @@ describe("parseServerMessage — extensions-changed (plan 05)", () => { expect(parseServerMessage(JSON.stringify({ type: "extensions-changed" }))).toBeNull(); }); }); + +describe("parseServerMessage — layout (plan 02)", () => { + const TREE = { version: 1, root: { kind: "leaf", view: "chat" } }; + + it("parses the broadcast (workspaceId + full tree)", () => { + const result = parseServerMessage( + JSON.stringify({ type: "layout", workspaceId: "ws-a", tree: TREE }), + ); + expect(result).toEqual({ type: "layout", workspaceId: "ws-a", tree: TREE }); + }); + + it("rejects a missing/invalid workspaceId", () => { + expect(parseServerMessage(JSON.stringify({ type: "layout", tree: TREE }))).toBeNull(); + expect(parseServerMessage(JSON.stringify({ type: "layout", workspaceId: 7, tree: TREE }))).toBeNull(); + }); + + it("rejects a missing/invalid tree (wrong version, missing root, non-object)", () => { + expect(parseServerMessage(JSON.stringify({ type: "layout", workspaceId: "ws-a" }))).toBeNull(); + expect( + parseServerMessage( + JSON.stringify({ type: "layout", workspaceId: "ws-a", tree: { version: 2, root: {} } }), + ), + ).toBeNull(); + expect( + parseServerMessage( + JSON.stringify({ type: "layout", workspaceId: "ws-a", tree: { version: 1 } }), + ), + ).toBeNull(); + expect(parseServerMessage(JSON.stringify({ type: "layout", workspaceId: "ws-a", tree: 7 }))).toBeNull(); + }); + + it("an unknown type still parses to null (the layout addition doesn't loosen the guard)", () => { + expect(parseServerMessage(JSON.stringify({ type: "layout.unknown" }))).toBeNull(); + }); +}); diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts index 696655b..6cbc559 100644 --- a/src/adapters/ws/logic.ts +++ b/src/adapters/ws/logic.ts @@ -5,6 +5,7 @@ import type { ConversationOpenMessage, ConversationStatusChangedMessage, ExtensionsChangedMessage, + LayoutChangedMessage, WsClientMessage, WsServerMessage, } from "@dispatch/transport-contract"; @@ -26,6 +27,7 @@ const VALID_SERVER_TYPES = new Set([ "conversation.statusChanged", "conversation.compacted", "extensions-changed", + "layout", ]); /** Serialize a client message to a JSON string for the wire. */ @@ -180,6 +182,30 @@ export function parseServerMessage(data: string): WsServerMessage | null { }; return msg; } + case "layout": { + // Layout-provider broadcast (plan 02 §2.5): the FULL tree for ONE + // workspace (decision 7 — no patch protocol). Light shape checks here + // (the tree's structural validation is the backend's write-time job); + // the store filters by its active workspace. + if (typeof parsed.workspaceId !== "string") return null; + const tree = parsed.tree; + if ( + tree === null || + typeof tree !== "object" || + Array.isArray(tree) || + (tree as { version?: unknown }).version !== 1 || + typeof (tree as { root?: unknown }).root !== "object" || + (tree as { root?: unknown }).root === null + ) { + return null; + } + const msg: LayoutChangedMessage = { + type: "layout", + workspaceId: parsed.workspaceId, + tree: tree as LayoutChangedMessage["tree"], + }; + return msg; + } default: return null; } diff --git a/src/app/App.svelte b/src/app/App.svelte index b7eb783..9eaba9c 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -10,18 +10,10 @@