summaryrefslogtreecommitdiffhomepage
path: root/src/app/surface-protocol.svelte.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-21 04:49:38 +0900
committerAdam Malczewski <[email protected]>2026-10-21 04:49:38 +0900
commita7b041f0f8989373404c35d3fe4b20b1e89d2440 (patch)
treec217da54783bdcdd6cc2cbbb0706500bdd65f20b /src/app/surface-protocol.svelte.ts
parenta62a242133a369aed0eeb0d4058eb05312551946 (diff)
downloaddispatch-web-a7b041f0f8989373404c35d3fe4b20b1e89d2440.tar.gz
dispatch-web-a7b041f0f8989373404c35d3fe4b20b1e89d2440.zip
fix(review-7.7): extract cohesive app store modules
Diffstat (limited to 'src/app/surface-protocol.svelte.ts')
-rw-r--r--src/app/surface-protocol.svelte.ts122
1 files changed, 122 insertions, 0 deletions
diff --git a/src/app/surface-protocol.svelte.ts b/src/app/surface-protocol.svelte.ts
new file mode 100644
index 0000000..4ba684d
--- /dev/null
+++ b/src/app/surface-protocol.svelte.ts
@@ -0,0 +1,122 @@
+import type { SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract";
+import type { SurfaceSocketHandle } from "../adapters/ws";
+import {
+ applyServerMessage,
+ getSurfaceSpec,
+ type ProtocolState,
+ initialState as protocolInitialState,
+ invoke as protocolInvoke,
+ subscribe as protocolSubscribe,
+ unsubscribe as protocolUnsubscribe,
+} from "../core/protocol";
+
+/** Shared app/plugin surface operations over the single app protocol state. */
+export function createSurfaceProtocol(deps: {
+ focusedConversationId: () => string | undefined;
+ send: SurfaceSocketHandle["send"];
+}) {
+ let protocol = $state<ProtocolState>(protocolInitialState());
+ function handleServerMessage(msg: SurfaceServerMessage): void {
+ protocol = applyServerMessage(protocol, msg);
+ // Surfaces are auto-expanded: whenever the catalog changes, subscribe to
+ // every entry (and drop subscriptions for entries that vanished).
+ if (msg.type === "catalog") {
+ syncSubscriptions();
+ }
+ }
+
+ /**
+ * Subscribe to every catalog entry, scoped to the focused conversation, and
+ * unsubscribe stragglers. Re-run on conversation switch: a conversation-scoped
+ * surface (e.g. cache-warming) re-scopes to the new id (`protocolSubscribe`
+ * emits unsubscribe-old + subscribe-new); a global surface ignores the id.
+ */
+ function syncSubscriptions(): void {
+ const cid = deps.focusedConversationId();
+ for (const entry of protocol.catalog) {
+ // A GLOBAL surface ignores conversation scope — subscribe it WITHOUT an id
+ // so a conversation switch doesn't churn a redundant unsubscribe+subscribe
+ // round trip ([email protected] catalog `scope`; ABSENT = assume
+ // conversation-scoped, the conservative pre-0.2.0 policy).
+ const scoped = entry.scope === "global" ? undefined : cid;
+ const result = protocolSubscribe(protocol, entry.id, scoped);
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ deps.send(msg);
+ }
+ }
+ const catalogIds = new Set(protocol.catalog.map((e) => e.id));
+ for (const id of [...protocol.subscriptions.keys()]) {
+ if (!catalogIds.has(id)) {
+ const result = protocolUnsubscribe(protocol, id);
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ deps.send(msg);
+ }
+ }
+ }
+ }
+
+ /**
+ * The surface OP implementations — closure functions shared by the app
+ * store's public methods AND the frontend-plugin ABI's surfaces ports (the
+ * plugin host binds exactly these; there is no second protocol state).
+ */
+ function invokeSurfaceOp(
+ surfaceId: string,
+ actionId: string,
+ payload?: unknown,
+ conversationId?: string,
+ ): void {
+ const result = protocolInvoke(
+ protocol,
+ surfaceId,
+ actionId,
+ payload,
+ conversationId ?? deps.focusedConversationId(),
+ );
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ deps.send(msg);
+ }
+ }
+
+ function subscribeSurfaceOp(surfaceId: string, conversationId?: string): void {
+ const result = protocolSubscribe(
+ protocol,
+ surfaceId,
+ conversationId ?? deps.focusedConversationId(),
+ );
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ deps.send(msg);
+ }
+ }
+
+ function unsubscribeSurfaceOp(surfaceId: string, conversationId?: string): void {
+ // protocolUnsubscribe keys on the surface alone (it drops the pair it
+ // was subscribed under); the optional id documents the intent.
+ void conversationId;
+ const result = protocolUnsubscribe(protocol, surfaceId);
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ deps.send(msg);
+ }
+ }
+
+ function surfaceSpecOp(surfaceId: string): SurfaceSpec | null {
+ return getSurfaceSpec(protocol, surfaceId);
+ }
+
+ return {
+ handleServerMessage,
+ syncSubscriptions,
+ invokeSurfaceOp,
+ subscribeSurfaceOp,
+ unsubscribeSurfaceOp,
+ surfaceSpecOp,
+ get protocol() {
+ return protocol;
+ },
+ };
+}