phoenix_duskmoon 10.0.12 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,32 @@
1
+ # [10.1.0](https://github.com/duskmoon-dev/phoenix-duskmoon-ui/compare/v10.0.12...v10.1.0) (2026-11-07)
2
+
3
+ ### Added
4
+
5
+ - Add opt-in structured composer protocol v1 to React Chat, including semantic
6
+ documents, context-scoped projections, explicit admission receipts, uncertain
7
+ submission recovery, and queue removal through LiveView events.
8
+ - Demonstrate composer permissions, commands, steering, queued follow-ups, and
9
+ reconnect recovery in Storybook while preserving the legacy text submission API.
10
+ - Expose the optional rich editor through lazy imports, with packaged consumer
11
+ checks that keep Lexical out of ordinary chat and hooks bundles.
12
+
13
+ ### Changed
14
+
15
+ - Adopt Core/CSS Art 1.21.0, Elements/Art Elements 1.10.2, and React Components
16
+ 0.6.0 with optional Lexical 0.53.0 peers.
17
+ - Update the published Bundler cohort to 10.1.2, the HTTP clients to 1.0.0-rc.5,
18
+ Gaoxy HTTP and its WebSock adapter to 0.15.1, and Tailwind to 4.3.5.
19
+ - Build Storybook images on Ubuntu 26.04 to meet the published native
20
+ toolchain's glibc 2.43 requirement.
21
+
22
+ ### Fixed
23
+
24
+ - Restore production HTTP admission in private cgroup namespaces by adopting
25
+ Gaoxy's mount visibility fix for overmounted memory controllers.
26
+ - Preserve React-owned values for disabled native form controls instead of
27
+ removing them when the browser omits disabled fields, resolving
28
+ [#210](https://github.com/duskmoon-dev/phoenix-duskmoon-ui/issues/210).
29
+
1
30
  # [10.0.12](https://github.com/duskmoon-dev/phoenix-duskmoon-ui/compare/v10.0.11...v10.0.12) (2026-11-01)
2
31
 
3
32
  ### Fixed
@@ -3,6 +3,7 @@ import { Markdown } from "@duskmoon-dev/components/markdown";
3
3
  import { Input } from "@duskmoon-dev/components/input";
4
4
  import { Button } from "@duskmoon-dev/components/button";
5
5
  import { Chat } from "@duskmoon-dev/components/chat";
6
+ import { requestId } from "./protocol.js";
6
7
  const h = React.createElement;
7
8
  const defaultOptions = {};
8
9
 
@@ -131,21 +132,6 @@ const Message = memo(function Message(props) {
131
132
  );
132
133
  });
133
134
 
134
- function requestId() {
135
- if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
136
- const bytes = crypto.getRandomValues(new Uint8Array(16));
137
- bytes[6] = (bytes[6] & 0x0f) | 0x40;
138
- bytes[8] = (bytes[8] & 0x3f) | 0x80;
139
- const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
140
- return [
141
- hex.slice(0, 4).join(""),
142
- hex.slice(4, 6).join(""),
143
- hex.slice(6, 8).join(""),
144
- hex.slice(8, 10).join(""),
145
- hex.slice(10).join(""),
146
- ].join("-");
147
- }
148
-
149
135
  // A composer owns its draft/attachments. This helper only owns admission and UI requests.
150
136
  export function useChatSubmission({ bridge, canSubmit }) {
151
137
  const [busy, setBusy] = useState(false);
@@ -165,6 +151,9 @@ export function useChatSubmission({ bridge, canSubmit }) {
165
151
  setBusy(true);
166
152
  setNotice("");
167
153
  try {
154
+ const structured =
155
+ payload.protocolVersion !== undefined || payload.document !== undefined;
156
+ if (structured) return await bridge.submit(payload);
168
157
  return await bridge.send("send", {
169
158
  request_id: requestId(),
170
159
  ...payload,
@@ -237,6 +226,7 @@ export function ReactChat({
237
226
  connected,
238
227
  label,
239
228
  options = defaultOptions,
229
+ adapter,
240
230
  }) {
241
231
  const transcriptId = useId();
242
232
  const scroll = useRef(null);
@@ -258,6 +248,11 @@ export function ReactChat({
258
248
  state,
259
249
  bridge,
260
250
  connected,
251
+ adapter,
252
+ scope: state.scope,
253
+ instanceGeneration: state.instanceGeneration,
254
+ composer: state.composer,
255
+ providers: adapter?.providers,
261
256
  synchronizing: state.needsSync,
262
257
  metadata: state.metadata,
263
258
  runtime: state.runtime,
@@ -344,9 +339,13 @@ export function ReactChat({
344
339
  : "Disconnected. Draft preserved.",
345
340
  ),
346
341
  options.renderStatus && h(options.renderStatus, context),
342
+ state.error && h("p", { role: "alert" }, state.error),
347
343
  h(options.Composer || DefaultComposer, {
348
344
  ...context,
349
- key: state.conversationId,
345
+ key:
346
+ state.protocolVersion === 1
347
+ ? state.instanceGeneration
348
+ : state.conversationId,
350
349
  }),
351
350
  );
352
351
  }
@@ -1,28 +1,51 @@
1
1
  import React from "react";
2
2
  import { createRoot } from "react-dom/client";
3
+ import {
4
+ snapshotComposerDocument,
5
+ snapshotComposerReceipt,
6
+ } from "@duskmoon-dev/components/chat";
3
7
  import { ReactChat } from "./chat.js";
4
- import { initialState, reduceChat } from "./state.js";
8
+ import {
9
+ initialState,
10
+ reduceChat,
11
+ sameChatScope,
12
+ validChatQueue,
13
+ } from "./state.js";
14
+ import {
15
+ assertChatJSON,
16
+ ChatTransportError,
17
+ correlatedReceipt,
18
+ requestId,
19
+ structuredSnapshot,
20
+ submissionSnapshot,
21
+ unknownReceipt,
22
+ chatJSONKey,
23
+ } from "./protocol.js";
5
24
 
6
25
  export { ReactChat, useChatSubmission } from "./chat.js";
7
26
  export { initialState, reduceChat } from "./state.js";
27
+ export { ChatTransportError } from "./protocol.js";
28
+
29
+ let nextGeneration = 0;
8
30
 
9
31
  export function createDuskmoonReactChat(options = {}) {
10
32
  return {
11
33
  mounted() {
12
- this.config = JSON.parse(this.el.dataset.chat);
13
- this.state = initialState(
14
- this.config.conversationId,
15
- this.config.messages,
16
- this.config,
17
- );
18
- this.root = createRoot(this.el);
19
34
  this.connected = true;
20
35
  this.requests = new Set();
21
- this.bridge = {
22
- send: (action, payload) => this.sendChat(action, payload),
23
- };
36
+ this.recoveryFailures = new Map();
37
+ this.bindContext(JSON.parse(this.el.dataset.chat));
38
+ this.root = createRoot(this.el);
24
39
  this.eventRef = this.handleEvent(this.config.event, (event) => {
25
40
  if (event.id !== this.el.id) return;
41
+ if (
42
+ ["chat.snapshot", "chat.reset"].includes(event.type) &&
43
+ event.conversation_id === this.state.conversationId &&
44
+ event.protocolVersion === 1 &&
45
+ sameChatScope(event.scope, this.state.scope) &&
46
+ event.instanceGeneration === this.state.instanceGeneration
47
+ )
48
+ this.reconcileReceipts(event.receipts);
26
49
  const previous = this.state;
27
50
  this.state = reduceChat(this.state, event);
28
51
  if (previous === this.state) return;
@@ -31,63 +54,449 @@ export function createDuskmoonReactChat(options = {}) {
31
54
  });
32
55
  this.renderChat();
33
56
  },
34
- sendChat(action, payload) {
35
- if (!this.connected || this.dead)
36
- return Promise.reject(new Error("Chat is disconnected"));
57
+ bindContext(config) {
58
+ if (config.protocolVersion != null && config.protocolVersion !== 1)
59
+ throw new TypeError("Unsupported chat protocol version");
60
+ const generation = ++nextGeneration;
61
+ if (
62
+ config.protocolVersion === 1 &&
63
+ (!config.scope?.contextKey ||
64
+ config.scope.conversationId !== config.conversationId)
65
+ )
66
+ throw new TypeError(
67
+ "Structured chat requires an application context key and matching conversation",
68
+ );
69
+ this.config = config;
70
+ this.state = initialState(config.conversationId, config.messages, {
71
+ ...config,
72
+ instanceGeneration: generation,
73
+ });
74
+ this.adapter =
75
+ options.adapter?.({
76
+ scope: this.state.scope,
77
+ instanceGeneration: generation,
78
+ config,
79
+ element: this.el,
80
+ }) || {};
81
+ this.submissionPending = false;
82
+ this.syncPending = null;
83
+ const current = () => {
84
+ if (this.dead || generation !== this.state.instanceGeneration)
85
+ throw new Error("Chat context has changed");
86
+ };
87
+ this.bridge = {
88
+ send: (action, payload) => {
89
+ try {
90
+ current();
91
+ return this.sendChat(action, payload);
92
+ } catch (error) {
93
+ return Promise.reject(error);
94
+ }
95
+ },
96
+ submit: async (snapshot, signal) => {
97
+ current();
98
+ return this.submitStructured(snapshot, signal);
99
+ },
100
+ reconcile: async (snapshot, signal) => {
101
+ current();
102
+ return this.reconcileSubmission(snapshot, signal);
103
+ },
104
+ mutateQueue: async (mutation, signal) => {
105
+ current();
106
+ return this.mutateQueue(mutation, signal);
107
+ },
108
+ getRecoveryFailures: () => [...this.recoveryFailures.values()],
109
+ };
110
+ },
111
+ // Explicit host operation. Mutating data-chat alone never changes ownership.
112
+ switchContext(config) {
113
+ if (config.event !== this.config.event)
114
+ throw new TypeError("Remount the hook to change its event channel");
115
+ this.requests.forEach((request) => request.cancel());
116
+ this.bindContext(config);
117
+ this.renderChat();
118
+ },
119
+ checkAction(action) {
120
+ if (
121
+ typeof this.config.actions?.[action] !== "string" ||
122
+ !this.config.actions[action].trim()
123
+ )
124
+ throw new TypeError(`Chat action is not configured: ${action}`);
125
+ if (!this.connected || this.dead) throw new Error("Chat is disconnected");
37
126
  if (action === "send" && this.state.needsSync)
38
- return Promise.reject(new Error("Chat is synchronizing"));
127
+ throw new Error("Chat is synchronizing");
128
+ if (this.requests.size >= (options.maxPendingRequests ?? 64))
129
+ throw new Error(
130
+ "Chat transport capacity reached; reconcile pending requests",
131
+ );
132
+ },
133
+ sendChat(action, payload = {}) {
134
+ if (
135
+ action === "send" &&
136
+ (payload.protocolVersion !== undefined ||
137
+ payload.document !== undefined)
138
+ )
139
+ return this.submitStructured(payload);
140
+ return this.transport(action, payload);
141
+ },
142
+ transport(action, payload, structured) {
143
+ try {
144
+ this.checkAction(action);
145
+ assertChatJSON(payload);
146
+ } catch (error) {
147
+ return Promise.reject(error);
148
+ }
149
+ const generation = this.state.instanceGeneration;
150
+ const scope = this.state.scope;
151
+ const correlation = {
152
+ request_id: payload.request_id ?? requestId(),
153
+ instanceGeneration: generation,
154
+ scope,
155
+ clientSubmissionId: structured?.snapshot?.clientSubmissionId,
156
+ };
157
+ if (
158
+ typeof correlation.request_id !== "string" ||
159
+ !correlation.request_id ||
160
+ [...this.requests].some((r) => r.id === correlation.request_id)
161
+ )
162
+ return Promise.reject(
163
+ new TypeError("Chat request_id must be unique while pending"),
164
+ );
165
+ const body = {
166
+ ...payload,
167
+ request_id: correlation.request_id,
168
+ id: this.el.id,
169
+ conversation_id: this.state.conversationId,
170
+ ...(this.state.protocolVersion === 1
171
+ ? { protocolVersion: 1, scope, instanceGeneration: generation }
172
+ : {}),
173
+ };
39
174
  return new Promise((resolve, reject) => {
175
+ const uncertain = (message, cause) =>
176
+ new ChatTransportError(
177
+ message,
178
+ { ...correlation, dispatched: true },
179
+ cause,
180
+ );
40
181
  const finish = (error, reply) => {
41
182
  if (!this.requests.delete(request)) return;
42
183
  clearTimeout(request.timer);
184
+ structured?.signal?.removeEventListener("abort", request.cancel);
43
185
  error ? reject(error) : resolve(reply);
44
186
  };
45
187
  const request = {
188
+ id: correlation.request_id,
46
189
  cancel: () =>
47
190
  finish(
48
- new Error("Chat disconnected; check status before retrying"),
191
+ uncertain("Chat disconnected; check status before retrying"),
49
192
  ),
50
193
  };
194
+ if (structured?.signal?.aborted) {
195
+ reject(new Error("Chat request aborted before dispatch"));
196
+ return;
197
+ }
51
198
  this.requests.add(request);
199
+ structured?.signal?.addEventListener("abort", request.cancel, {
200
+ once: true,
201
+ });
52
202
  request.timer = setTimeout(
53
203
  () =>
54
204
  finish(
55
- new Error("Chat request timed out; check status before retrying"),
205
+ uncertain("Chat request timed out; check status before retrying"),
56
206
  ),
57
- 15000,
207
+ options.requestTimeoutMs ?? 15000,
58
208
  );
59
- const body = {
60
- ...payload,
61
- id: this.el.id,
62
- conversation_id: this.state.conversationId,
63
- };
64
209
  const reply = (response) => {
65
- const error =
66
- response?.status === "ok"
67
- ? null
68
- : new Error(response?.message || "Chat request rejected");
69
- if (error) error.response = response;
70
- finish(error, response);
210
+ try {
211
+ if (structured) {
212
+ const receipt = structured.validate(response, correlation);
213
+ structured.onReceipt?.(receipt);
214
+ // A late receipt resolves its original recovery record only.
215
+ if (generation !== this.state.instanceGeneration || this.dead) {
216
+ finish(uncertain("Chat context has changed"));
217
+ return;
218
+ }
219
+ finish(null, receipt);
220
+ } else if (response?.status === "ok") finish(null, response);
221
+ else if (response?.status === "error") {
222
+ const error = new Error(
223
+ response.message || "Chat request rejected",
224
+ );
225
+ error.response = response;
226
+ finish(error);
227
+ } else
228
+ finish(uncertain("Missing or malformed chat acknowledgement"));
229
+ } catch (error) {
230
+ finish(
231
+ uncertain(
232
+ "Invalid chat acknowledgement; check status before retrying",
233
+ error,
234
+ ),
235
+ );
236
+ }
71
237
  };
72
238
  try {
73
239
  const event = this.config.actions[action];
74
- if (this.config.target || this.el.dataset.target)
75
- this.pushEventTo(
76
- this.config.target || this.el.dataset.target,
77
- event,
78
- body,
79
- reply,
80
- );
240
+ const target = this.config.target || this.el.dataset.target;
241
+ if (target) this.pushEventTo(target, event, body, reply);
81
242
  else this.pushEvent(event, body, reply);
82
243
  } catch (error) {
83
- finish(error);
244
+ finish(uncertain("Chat dispatch outcome is unknown", error));
84
245
  }
85
246
  });
86
247
  },
87
- requestSync() {
88
- this.sendChat("sync", { sequences: this.state.sequences }).catch(
89
- () => {},
248
+ retain(adapter, attempt) {
249
+ try {
250
+ adapter.recovery.retain(attempt);
251
+ this.recoveryFailures.delete(attempt.snapshot.clientSubmissionId);
252
+ } catch (error) {
253
+ this.recoveryFailures.set(attempt.snapshot.clientSubmissionId, {
254
+ attempt,
255
+ error,
256
+ });
257
+ throw error;
258
+ }
259
+ },
260
+ async submitStructured(payload, signal) {
261
+ if (signal?.aborted)
262
+ throw new Error("Chat request aborted before dispatch");
263
+ this.checkAction("send");
264
+ if (this.submissionPending)
265
+ throw new Error("Chat is not accepting messages");
266
+ const snapshot = structuredSnapshot(payload, this.state);
267
+ const adapter = this.adapter;
268
+ if (!adapter.recovery?.retain || !adapter.recovery?.getSnapshot)
269
+ throw new TypeError(
270
+ "Structured submissions require an application recovery owner",
271
+ );
272
+ const generation = this.state.instanceGeneration;
273
+ const previous = adapter.recovery
274
+ .getSnapshot()
275
+ .find(
276
+ (a) => a.snapshot.clientSubmissionId === snapshot.clientSubmissionId,
277
+ );
278
+ if (previous && chatJSONKey(previous.snapshot) !== chatJSONKey(snapshot))
279
+ throw new TypeError(
280
+ "A logical submission ID cannot change its payload",
281
+ );
282
+ if (previous?.status === "accepted" || previous?.status === "rejected")
283
+ return previous.receipt;
284
+ const physicalId = payload.request_id ?? requestId();
285
+ const attempt = {
286
+ snapshot,
287
+ instanceId: this.el.id,
288
+ instanceGeneration: generation,
289
+ createdAt: previous?.createdAt ?? Date.now(),
290
+ status: "pending",
291
+ requestIds: [...(previous?.requestIds || []), physicalId],
292
+ };
293
+ this.submissionPending = true;
294
+ try {
295
+ // The upstream controller may already have retained this first attempt.
296
+ if (
297
+ previous &&
298
+ (previous.status === "unknown" || previous.requestIds?.length) &&
299
+ (await adapter.authorizeRetry?.(previous)) !== true
300
+ )
301
+ throw new Error(
302
+ "Explicit application authorization is required to retry an unresolved submission",
303
+ );
304
+ // Handoff is synchronous and must succeed before dispatch becomes possible.
305
+ this.retain(adapter, attempt);
306
+ if (generation !== this.state.instanceGeneration || this.dead)
307
+ throw new Error("Chat context has changed");
308
+ return await this.transport(
309
+ "send",
310
+ { ...snapshot, request_id: physicalId },
311
+ {
312
+ snapshot,
313
+ signal,
314
+ validate: (response, correlation) =>
315
+ correlatedReceipt(response, snapshot, correlation),
316
+ onReceipt: (receipt) =>
317
+ this.retain(adapter, {
318
+ ...attempt,
319
+ status: receipt.outcome,
320
+ receipt,
321
+ }),
322
+ },
323
+ );
324
+ } catch (error) {
325
+ if (!(error instanceof ChatTransportError)) throw error;
326
+ const receipt = unknownReceipt(snapshot, error);
327
+ this.retain(adapter, { ...attempt, status: "unknown", receipt });
328
+ return receipt;
329
+ } finally {
330
+ if (generation === this.state.instanceGeneration)
331
+ this.submissionPending = false;
332
+ }
333
+ },
334
+ async reconcileSubmission(payload, signal) {
335
+ const snapshot = submissionSnapshot(payload, this.state);
336
+ const adapter = this.adapter;
337
+ const attempt = adapter.recovery
338
+ ?.getSnapshot()
339
+ .find(
340
+ (a) => a.snapshot.clientSubmissionId === snapshot.clientSubmissionId,
341
+ );
342
+ if (!attempt || chatJSONKey(attempt.snapshot) !== chatJSONKey(snapshot))
343
+ throw new Error("No matching scoped recovery record for submission");
344
+ try {
345
+ return await this.transport(
346
+ "reconcile",
347
+ {
348
+ clientSubmissionId: snapshot.clientSubmissionId,
349
+ operation: snapshot.operation,
350
+ },
351
+ {
352
+ snapshot,
353
+ signal,
354
+ validate: (response, correlation) =>
355
+ correlatedReceipt(response, snapshot, correlation),
356
+ onReceipt: (receipt) =>
357
+ this.retain(adapter, {
358
+ ...attempt,
359
+ status: receipt.outcome,
360
+ receipt,
361
+ }),
362
+ },
363
+ );
364
+ } catch (error) {
365
+ if (!(error instanceof ChatTransportError)) throw error;
366
+ const receipt = unknownReceipt(snapshot, error);
367
+ this.retain(adapter, { ...attempt, status: "unknown", receipt });
368
+ return receipt;
369
+ }
370
+ },
371
+ async mutateQueue(mutation, signal) {
372
+ assertChatJSON(mutation);
373
+ if (
374
+ !["edit", "remove", "promote"].includes(mutation.operation) ||
375
+ !mutation.requestId
376
+ )
377
+ throw new TypeError("Unsupported queue operation");
378
+ const { composer, scope } = this.state;
379
+ const item = composer?.queue?.items.find(
380
+ (item) => item.id === mutation.itemId,
90
381
  );
382
+ const capability = composer?.capabilities?.queue?.[mutation.operation];
383
+ const itemCapability = item?.capabilities?.[mutation.operation];
384
+ if (
385
+ !sameChatScope(mutation.scope, scope) ||
386
+ !item ||
387
+ !capability ||
388
+ capability === "none" ||
389
+ !itemCapability ||
390
+ itemCapability === "none"
391
+ )
392
+ throw new TypeError("Unsupported queue operation or item");
393
+ if (mutation.expectedRevision !== composer.queue.revision)
394
+ throw new TypeError("Queue revision has changed");
395
+ if (mutation.operation === "promote" && !mutation.targetTurnId)
396
+ throw new TypeError("Promotion requires a target turn");
397
+ if (mutation.operation === "edit")
398
+ snapshotComposerDocument(mutation.document);
399
+ if (
400
+ mutation.operation === "edit" &&
401
+ (!mutation.document ||
402
+ ([capability, itemCapability].includes("text") &&
403
+ (mutation.document.command ||
404
+ mutation.document.content?.some((node) => node.type !== "text"))))
405
+ )
406
+ throw new TypeError("Queue editing supports text documents only");
407
+ const generation = this.state.instanceGeneration;
408
+ return this.transport(`queue_${mutation.operation}`, mutation, {
409
+ signal,
410
+ validate: (response, correlation) => {
411
+ const result = response?.result;
412
+ assertChatJSON(result);
413
+ if (
414
+ response?.request_id !== correlation.request_id ||
415
+ response?.instanceGeneration !== generation ||
416
+ result?.requestId !== mutation.requestId ||
417
+ !["accepted", "rejected"].includes(result.outcome) ||
418
+ !validChatQueue(result.projection, scope) ||
419
+ (result.outcome === "rejected" && !result.error?.code)
420
+ )
421
+ throw new TypeError("Queue response correlation mismatch");
422
+ return result;
423
+ },
424
+ onReceipt: (result) => {
425
+ if (this.dead || generation !== this.state.instanceGeneration) return;
426
+ const projection = result.projection;
427
+ if (
428
+ Number.isSafeInteger(projection.revision) &&
429
+ projection.revision > this.state.composer.queue.revision &&
430
+ Array.isArray(projection.items)
431
+ ) {
432
+ this.state = {
433
+ ...this.state,
434
+ composer: { ...this.state.composer, queue: projection },
435
+ };
436
+ this.scheduleRender();
437
+ }
438
+ },
439
+ });
440
+ },
441
+ reconcileReceipts(receipts = []) {
442
+ if (!this.adapter.reconcileOnSync || !this.adapter.recovery) return;
443
+ if (!Array.isArray(receipts)) receipts = [];
444
+ for (const attempt of this.adapter.recovery.getSnapshot()) {
445
+ if (
446
+ !sameChatScope(attempt.snapshot.scope, this.state.scope) ||
447
+ !["pending", "unknown"].includes(attempt.status)
448
+ )
449
+ continue;
450
+ const input = receipts.find(
451
+ (r) => r.clientSubmissionId === attempt.snapshot.clientSubmissionId,
452
+ );
453
+ let receipt;
454
+ try {
455
+ receipt = input
456
+ ? snapshotComposerReceipt(input, attempt.snapshot)
457
+ : unknownReceipt(
458
+ attempt.snapshot,
459
+ new Error("No authoritative receipt in snapshot"),
460
+ );
461
+ } catch (error) {
462
+ receipt = unknownReceipt(attempt.snapshot, error);
463
+ }
464
+ this.retain(this.adapter, {
465
+ ...attempt,
466
+ status: receipt.outcome,
467
+ receipt,
468
+ });
469
+ }
470
+ },
471
+ requestSync() {
472
+ if (this.syncPending) return this.syncPending;
473
+ const generation = this.state.instanceGeneration;
474
+ const payload = { sequences: this.state.sequences };
475
+ if (this.adapter.reconcileOnSync && this.adapter.recovery)
476
+ payload.unresolvedSubmissionIds = this.adapter.recovery
477
+ .getSnapshot()
478
+ .filter(
479
+ (a) =>
480
+ sameChatScope(a.snapshot.scope, this.state.scope) &&
481
+ ["pending", "unknown"].includes(a.status),
482
+ )
483
+ .map((a) => a.snapshot.clientSubmissionId);
484
+ const pending = this.sendChat("sync", payload)
485
+ .catch((error) => {
486
+ if (
487
+ !this.dead &&
488
+ generation === this.state.instanceGeneration &&
489
+ this.syncPending === pending
490
+ ) {
491
+ this.state = { ...this.state, error: error.message };
492
+ this.scheduleRender();
493
+ }
494
+ })
495
+ .finally(() => {
496
+ if (this.syncPending === pending) this.syncPending = null;
497
+ });
498
+ this.syncPending = pending;
499
+ return pending;
91
500
  },
92
501
  scheduleRender() {
93
502
  if (!this.frame)
@@ -104,12 +513,14 @@ export function createDuskmoonReactChat(options = {}) {
104
513
  connected: this.connected,
105
514
  label: this.config.label,
106
515
  options,
516
+ adapter: this.adapter,
107
517
  }),
108
518
  );
109
519
  },
110
520
  disconnected() {
111
521
  this.connected = false;
112
522
  this.requests.forEach((request) => request.cancel());
523
+ this.syncPending = null;
113
524
  this.renderChat();
114
525
  },
115
526
  reconnected() {