diff options
Diffstat (limited to 'src/adapters')
| -rw-r--r-- | src/adapters/ws/index.ts | 8 | ||||
| -rw-r--r-- | src/adapters/ws/logic.test.ts | 35 | ||||
| -rw-r--r-- | src/adapters/ws/logic.ts | 26 |
3 files changed, 69 insertions, 0 deletions
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; } |
