summaryrefslogtreecommitdiffhomepage
path: root/src/adapters
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-05 01:20:28 +0900
committerAdam Malczewski <[email protected]>2026-09-05 01:20:28 +0900
commit985116fdc8961e656cd9d3027debcd1a6237d6c2 (patch)
tree43aed23977ea354d23d8c54f27d45bf5e0635dfc /src/adapters
parenta104c094b588560f955ff52dc7f949cf3c355f94 (diff)
downloaddispatch-web-985116fdc8961e656cd9d3027debcd1a6237d6c2.tar.gz
dispatch-web-985116fdc8961e656cd9d3027debcd1a6237d6c2.zip
feat(layout): plan 02 — provider consumption + layout WS + localStorage migration
Diffstat (limited to 'src/adapters')
-rw-r--r--src/adapters/ws/index.ts8
-rw-r--r--src/adapters/ws/logic.test.ts35
-rw-r--r--src/adapters/ws/logic.ts26
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;
}