@opengeni/react 0.25.0 → 0.26.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.
Files changed (46) hide show
  1. package/dist/{chunk-AVPU5PMC.js → chunk-F4CFWKVI.js} +332 -64
  2. package/dist/chunk-F4CFWKVI.js.map +1 -0
  3. package/dist/chunk-GQN2QIR2.js +3767 -0
  4. package/dist/chunk-GQN2QIR2.js.map +1 -0
  5. package/dist/{chunk-I3BJZIG5.js → chunk-HIWPQYWI.js} +2 -120
  6. package/dist/chunk-HIWPQYWI.js.map +1 -0
  7. package/dist/chunk-M7X4JZOD.js +121 -0
  8. package/dist/chunk-M7X4JZOD.js.map +1 -0
  9. package/dist/{chunk-SHOFILHJ.js → chunk-MP6237JB.js} +570 -984
  10. package/dist/chunk-MP6237JB.js.map +1 -0
  11. package/dist/chunk-OZDLELJQ.js +937 -0
  12. package/dist/chunk-OZDLELJQ.js.map +1 -0
  13. package/dist/{chunk-RDPDU4TA.js → chunk-TMH6HZWF.js} +3 -3
  14. package/dist/{chunk-6XUS5VFM.js → chunk-YU5PGUK7.js} +6 -4
  15. package/dist/{chunk-6XUS5VFM.js.map → chunk-YU5PGUK7.js.map} +1 -1
  16. package/dist/{composer-BXb0Q1HF.d.ts → composer-DoC4veX1.d.ts} +4 -75
  17. package/dist/composer.d.ts +2 -1
  18. package/dist/composer.js +4 -3
  19. package/dist/index.d.ts +14 -259
  20. package/dist/index.js +1016 -5140
  21. package/dist/index.js.map +1 -1
  22. package/dist/machines.js +3 -2
  23. package/dist/session-Du_FsrZ1.d.ts +264 -0
  24. package/dist/session-ui-BqQDH7YV.d.ts +183 -0
  25. package/dist/session-ui.d.ts +7 -0
  26. package/dist/session-ui.js +17 -0
  27. package/dist/session-ui.js.map +1 -0
  28. package/dist/session.d.ts +3 -1
  29. package/dist/session.js +26 -9
  30. package/dist/use-file-attachments-C0kpHrs9.d.ts +76 -0
  31. package/dist/{session-BEvtFWhe.d.ts → use-turn-queue-3rkJwjFv.d.ts} +3 -185
  32. package/package.json +6 -2
  33. package/src/components/message-timeline.tsx +92 -52
  34. package/src/hooks/use-composer.ts +456 -69
  35. package/src/hooks/use-file-attachments.ts +1 -1
  36. package/src/hooks/use-goal.ts +1 -1
  37. package/src/hooks/use-session-events.ts +5 -0
  38. package/src/hooks/use-session-lineage.ts +1 -1
  39. package/src/hooks/use-session.ts +9 -6
  40. package/src/session-ui.ts +13 -0
  41. package/src/session.ts +15 -0
  42. package/src/timeline/activity-rail.tsx +1 -1
  43. package/dist/chunk-AVPU5PMC.js.map +0 -1
  44. package/dist/chunk-I3BJZIG5.js.map +0 -1
  45. package/dist/chunk-SHOFILHJ.js.map +0 -1
  46. /package/dist/{chunk-RDPDU4TA.js.map → chunk-TMH6HZWF.js.map} +0 -0
@@ -2,6 +2,7 @@ import type {
2
2
  ComposerDraft,
3
3
  EffectiveControlResumeOption,
4
4
  EffectiveSessionControl,
5
+ OpenGeniApiError,
5
6
  ResourceRef,
6
7
  SaveComposerDraftRequest,
7
8
  SendMessageInput,
@@ -37,6 +38,184 @@ export type UseComposerOptions = EmbeddedSessionClientOverride &
37
38
  draftPersistence?: "durable" | "disabled" | undefined;
38
39
  };
39
40
 
41
+ type ComposerDraftShadow = {
42
+ text: string;
43
+ resources: ResourceRef[];
44
+ };
45
+
46
+ type PendingComposerOperation = {
47
+ delivery: "send" | "steer";
48
+ input: SendMessageInput;
49
+ draftAtSend: string;
50
+ resourcesAtSend: ResourceRef[];
51
+ /** Latest local text/resources that must survive an uncertain delivery. */
52
+ newerShadow: ComposerDraftShadow;
53
+ clearDraftOnAccept: boolean;
54
+ /** False after remount when the original input carried secret credentials. */
55
+ canRetry: boolean;
56
+ };
57
+
58
+ type StoredPendingComposerOperation = Omit<PendingComposerOperation, "input" | "canRetry"> & {
59
+ input: Omit<SendMessageInput, "mcpCredentialUpdates">;
60
+ hasMcpCredentialUpdates: boolean;
61
+ };
62
+
63
+ const PENDING_COMPOSER_STORAGE_PREFIX = "opengeni.pending-composer.v1:";
64
+
65
+ // A remount must not manufacture a new operation while the previous mutation
66
+ // is still outcome-unknown. Keep only non-credential request fields here; the
67
+ // mounted hook retains the exact input, including any credential updates. The
68
+ // safe shadow is also session-scoped so a refresh cannot lose newer edits.
69
+ const pendingComposerOperations = new Map<string, StoredPendingComposerOperation>();
70
+
71
+ function pendingComposerOperationKey(
72
+ workspaceId: string,
73
+ sessionId: string | null | undefined,
74
+ ): string | null {
75
+ return sessionId ? `${workspaceId}\u0000${sessionId}` : null;
76
+ }
77
+
78
+ function pendingComposerStorage(): Storage | null {
79
+ try {
80
+ return typeof window === "undefined" ? null : window.sessionStorage;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ function pendingComposerStorageKey(key: string): string {
87
+ return `${PENDING_COMPOSER_STORAGE_PREFIX}${encodeURIComponent(key)}`;
88
+ }
89
+
90
+ function resourceList(value: unknown): value is ResourceRef[] {
91
+ return (
92
+ Array.isArray(value) &&
93
+ value.every((candidate) => {
94
+ if (typeof candidate !== "object" || candidate === null) return false;
95
+ const resource = candidate as Record<string, unknown>;
96
+ return resource.kind === "file"
97
+ ? typeof resource.fileId === "string"
98
+ : resource.kind === "repository" &&
99
+ typeof resource.uri === "string" &&
100
+ typeof resource.ref === "string";
101
+ })
102
+ );
103
+ }
104
+
105
+ function readStoredPendingComposerOperation(
106
+ key: string | null,
107
+ ): StoredPendingComposerOperation | null {
108
+ const storage = key ? pendingComposerStorage() : null;
109
+ if (!storage || !key) return null;
110
+ try {
111
+ const parsed: unknown = JSON.parse(storage.getItem(pendingComposerStorageKey(key)) ?? "null");
112
+ if (typeof parsed !== "object" || parsed === null) return null;
113
+ const record = parsed as Record<string, unknown>;
114
+ const input = record.input;
115
+ const shadow = record.newerShadow;
116
+ if (
117
+ typeof input !== "object" ||
118
+ input === null ||
119
+ typeof shadow !== "object" ||
120
+ shadow === null
121
+ ) {
122
+ return null;
123
+ }
124
+ const inputRecord = input as Record<string, unknown>;
125
+ const shadowRecord = shadow as Record<string, unknown>;
126
+ if (
127
+ (record.delivery !== "send" && record.delivery !== "steer") ||
128
+ typeof record.draftAtSend !== "string" ||
129
+ !resourceList(record.resourcesAtSend) ||
130
+ typeof shadowRecord.text !== "string" ||
131
+ !resourceList(shadowRecord.resources) ||
132
+ typeof record.clearDraftOnAccept !== "boolean" ||
133
+ typeof record.hasMcpCredentialUpdates !== "boolean" ||
134
+ typeof inputRecord.text !== "string" ||
135
+ typeof inputRecord.clientEventId !== "string" ||
136
+ "mcpCredentialUpdates" in inputRecord ||
137
+ ("resources" in inputRecord && !resourceList(inputRecord.resources))
138
+ ) {
139
+ return null;
140
+ }
141
+ return record as StoredPendingComposerOperation;
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ function writePendingComposerOperation(
148
+ key: string,
149
+ operation: StoredPendingComposerOperation,
150
+ ): void {
151
+ const storage = pendingComposerStorage();
152
+ if (!storage) return;
153
+ try {
154
+ storage.setItem(pendingComposerStorageKey(key), JSON.stringify(operation));
155
+ } catch {
156
+ // Storage is best effort; the in-memory record still protects remounts.
157
+ }
158
+ }
159
+
160
+ function restorePendingComposerOperation(key: string | null): PendingComposerOperation | null {
161
+ const stored =
162
+ (key && pendingComposerOperations.get(key)) ?? readStoredPendingComposerOperation(key);
163
+ if (!stored) return null;
164
+ return {
165
+ ...stored,
166
+ input: stored.input,
167
+ newerShadow: stored.newerShadow ?? {
168
+ text: stored.draftAtSend,
169
+ resources: stored.resourcesAtSend,
170
+ },
171
+ canRetry: !stored.hasMcpCredentialUpdates,
172
+ };
173
+ }
174
+
175
+ function rememberPendingComposerOperation(
176
+ key: string | null,
177
+ operation: PendingComposerOperation,
178
+ ): void {
179
+ if (!key) return;
180
+ const { canRetry: _retry, ...safeOperation } = operation;
181
+ const { input: originalInput, ...withoutInput } = safeOperation;
182
+ const { mcpCredentialUpdates: _storedMcp, ...safeInput } = originalInput;
183
+ const stored = {
184
+ ...withoutInput,
185
+ input: safeInput,
186
+ hasMcpCredentialUpdates: operation.input.mcpCredentialUpdates !== undefined,
187
+ newerShadow: {
188
+ text: operation.newerShadow.text,
189
+ resources: [...operation.newerShadow.resources],
190
+ },
191
+ } satisfies StoredPendingComposerOperation;
192
+ pendingComposerOperations.set(key, stored);
193
+ writePendingComposerOperation(key, stored);
194
+ }
195
+
196
+ function forgetPendingComposerOperation(key: string | null): void {
197
+ if (!key) return;
198
+ pendingComposerOperations.delete(key);
199
+ const storage = pendingComposerStorage();
200
+ if (!storage) return;
201
+ try {
202
+ storage.removeItem(pendingComposerStorageKey(key));
203
+ } catch {
204
+ // Ignore a blocked storage implementation; delivery has already settled.
205
+ }
206
+ }
207
+
208
+ function updatePendingComposerShadow(
209
+ key: string | null,
210
+ operation: PendingComposerOperation | null,
211
+ shadow: ComposerDraftShadow,
212
+ ): PendingComposerOperation | null {
213
+ if (!key || !operation) return operation;
214
+ const next = { ...operation, newerShadow: { ...shadow, resources: [...shadow.resources] } };
215
+ rememberPendingComposerOperation(key, next);
216
+ return next;
217
+ }
218
+
40
219
  export type ComposerState = {
41
220
  value: string;
42
221
  setValue: (value: string) => void;
@@ -84,7 +263,10 @@ export function useComposer(
84
263
  const { client, workspaceId, registerSessionReconciler } = useEmbeddedSession(options);
85
264
  const durableDrafts = options.draftPersistence !== "disabled";
86
265
  const targetKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${durableDrafts ? "durable" : "disabled"}`;
87
- const [value, setValue] = useState("");
266
+ const pendingOperationKey = pendingComposerOperationKey(workspaceId, sessionId);
267
+ const initialPendingOperation = restorePendingComposerOperation(pendingOperationKey);
268
+ const initialShadow = initialPendingOperation?.newerShadow;
269
+ const [value, setValue] = useState(() => initialShadow?.text ?? "");
88
270
  // Keep rendered state behind the committed target identity for one frame:
89
271
  // a parent may switch sessionId without remounting this public hook.
90
272
  const [stateTargetKey, setStateTargetKey] = useState(targetKey);
@@ -96,18 +278,31 @@ export function useComposer(
96
278
  const [draftLoading, setDraftLoading] = useState(Boolean(sessionId) && durableDrafts);
97
279
  const [draftSaving, setDraftSaving] = useState(false);
98
280
  const [draftConflict, setDraftConflict] = useState<Error | null>(null);
99
- const [restoredResources, setRestoredResources] = useState<ResourceRef[]>([]);
100
- const pendingClientEventId = useRef<string | null>(null);
101
- const valueRef = useRef("");
281
+ const [restoredResources, setRestoredResources] = useState<ResourceRef[]>(
282
+ () => initialShadow?.resources ?? [],
283
+ );
284
+ const pendingOperationRef = useRef<PendingComposerOperation | null>(initialPendingOperation);
285
+ const pendingClientEventId = useRef<string | null>(
286
+ initialPendingOperation?.input.clientEventId ?? null,
287
+ );
288
+ const valueRef = useRef(initialShadow?.text ?? "");
102
289
  const draftRef = useRef<ComposerDraft | null>(null);
103
- const restoredResourcesRef = useRef<ResourceRef[]>([]);
104
- const localEditRevision = useRef(0);
290
+ const restoredResourcesRef = useRef<ResourceRef[]>(initialShadow?.resources ?? []);
291
+ const localEditRevision = useRef(initialShadow ? 1 : 0);
105
292
  const targetGeneration = useRef(0);
106
293
  const draftReadGeneration = useRef(0);
107
294
  const lastSavedSignature = useRef<string | null>(null);
108
295
  const saveChain = useRef<Promise<void>>(Promise.resolve());
109
296
  const onSent = options.onSent;
110
297
  const onDraftApplied = options.onDraftApplied;
298
+ // Read through a ref so live session/policy projections can replace their
299
+ // apply callback without invalidating the draft loader and re-running its
300
+ // initial-load effect. Publish only committed callbacks: a suspended target
301
+ // render must not retarget an in-flight read owned by the committed session.
302
+ const onDraftAppliedRef = useRef(onDraftApplied);
303
+ useLayoutEffect(() => {
304
+ onDraftAppliedRef.current = onDraftApplied;
305
+ }, [onDraftApplied]);
111
306
  // Read through a ref so a new extras closure (created every render by
112
307
  // callers passing inline functions) does not invalidate `send`.
113
308
  const sendExtrasRef = useRef(options.sendExtras);
@@ -124,17 +319,19 @@ export function useComposer(
124
319
  targetKeyRef.current = targetKey;
125
320
  targetGeneration.current += 1;
126
321
  draftReadGeneration.current += 1;
127
- pendingClientEventId.current = null;
128
- localEditRevision.current = 0;
129
- valueRef.current = "";
322
+ pendingOperationRef.current = restorePendingComposerOperation(pendingOperationKey);
323
+ pendingClientEventId.current = pendingOperationRef.current?.input.clientEventId ?? null;
324
+ const shadow = pendingOperationRef.current?.newerShadow;
325
+ localEditRevision.current = shadow ? 1 : 0;
326
+ valueRef.current = shadow?.text ?? "";
130
327
  draftRef.current = null;
131
- restoredResourcesRef.current = [];
328
+ restoredResourcesRef.current = shadow?.resources ?? [];
132
329
  lastSavedSignature.current = null;
133
330
  // Old saves may still be awaiting the network. Their generation fence
134
331
  // prevents settlement, and a fresh chain avoids blocking this target.
135
332
  saveChain.current = Promise.resolve();
136
333
  setStateTargetKey(targetKey);
137
- setValue("");
334
+ setValue(shadow?.text ?? "");
138
335
  setSending(false);
139
336
  setPausing(false);
140
337
  setResuming(false);
@@ -143,14 +340,13 @@ export function useComposer(
143
340
  setDraftLoading(Boolean(sessionId) && durableDrafts);
144
341
  setDraftSaving(false);
145
342
  setDraftConflict(null);
146
- setRestoredResources([]);
147
- }, [durableDrafts, sessionId, targetKey]);
343
+ setRestoredResources(shadow?.resources ?? []);
344
+ }, [durableDrafts, pendingOperationKey, sessionId, targetKey]);
148
345
 
149
346
  const applyDraft = useCallback(
150
347
  (next: ComposerDraft): void => {
151
348
  if (targetKeyRef.current !== targetKey) return;
152
349
  if (!durableDrafts) {
153
- pendingClientEventId.current = null;
154
350
  localEditRevision.current += 1;
155
351
  valueRef.current = next.text;
156
352
  restoredResourcesRef.current = next.resources;
@@ -159,6 +355,17 @@ export function useComposer(
159
355
  setDraft(null);
160
356
  setValue(next.text);
161
357
  setRestoredResources(next.resources);
358
+ pendingOperationRef.current = updatePendingComposerShadow(
359
+ pendingOperationKey,
360
+ pendingOperationRef.current,
361
+ {
362
+ text: next.text,
363
+ resources: mergeResources(
364
+ next.resources,
365
+ resolveSendExtras(sendExtrasRef.current).resources ?? [],
366
+ ),
367
+ },
368
+ );
162
369
  setDraftConflict(null);
163
370
  return;
164
371
  }
@@ -167,14 +374,24 @@ export function useComposer(
167
374
  restoredResourcesRef.current = next.resources;
168
375
  lastSavedSignature.current = draftSignature(draftPayload(next));
169
376
  localEditRevision.current += 1;
170
- pendingClientEventId.current = null;
171
377
  setDraft(next);
172
378
  setValue(next.text);
173
379
  setRestoredResources(next.resources);
380
+ pendingOperationRef.current = updatePendingComposerShadow(
381
+ pendingOperationKey,
382
+ pendingOperationRef.current,
383
+ {
384
+ text: next.text,
385
+ resources: mergeResources(
386
+ next.resources,
387
+ resolveSendExtras(sendExtrasRef.current).resources ?? [],
388
+ ),
389
+ },
390
+ );
174
391
  setDraftConflict(null);
175
- onDraftApplied?.(next);
392
+ onDraftAppliedRef.current?.(next);
176
393
  },
177
- [durableDrafts, onDraftApplied, targetKey],
394
+ [durableDrafts, pendingOperationKey, targetKey],
178
395
  );
179
396
 
180
397
  const loadDraft = useCallback(
@@ -218,7 +435,16 @@ export function useComposer(
218
435
  draftRef.current = fetched;
219
436
  setDraft(fetched);
220
437
  setDraftConflict(null);
221
- if (
438
+ const shadow = pendingOperationRef.current?.newerShadow;
439
+ if (shadow) {
440
+ // The server can only know the original operation. Never replace
441
+ // newer local edits while that operation is still uncertain.
442
+ valueRef.current = shadow.text;
443
+ restoredResourcesRef.current = shadow.resources;
444
+ localEditRevision.current ||= 1;
445
+ setValue(shadow.text);
446
+ setRestoredResources(shadow.resources);
447
+ } else if (
222
448
  replaceLocal ||
223
449
  (!localWasDirtyAtStart && localAtStart === localEditRevision.current)
224
450
  ) {
@@ -227,7 +453,7 @@ export function useComposer(
227
453
  lastSavedSignature.current = draftSignature(draftPayload(fetched));
228
454
  setValue(fetched.text);
229
455
  setRestoredResources(fetched.resources);
230
- onDraftApplied?.(fetched);
456
+ onDraftAppliedRef.current?.(fetched);
231
457
  }
232
458
  }
233
459
  } catch (cause) {
@@ -248,7 +474,7 @@ export function useComposer(
248
474
  }
249
475
  }
250
476
  },
251
- [client, durableDrafts, onDraftApplied, sessionId, targetKey, workspaceId],
477
+ [client, durableDrafts, sessionId, targetKey, workspaceId],
252
478
  );
253
479
 
254
480
  useEffect(() => {
@@ -325,7 +551,7 @@ export function useComposer(
325
551
  targetGeneration.current === ownedGeneration
326
552
  ) {
327
553
  const problem = asError(cause);
328
- setDraftConflict(problem);
554
+ if (isDraftConflictError(problem)) setDraftConflict(problem);
329
555
  setError(problem);
330
556
  }
331
557
  } finally {
@@ -347,6 +573,27 @@ export function useComposer(
347
573
  // Private durable autosave. A newer local edit is never replaced by an older
348
574
  // response; saves serialize and each reads the latest acknowledged revision.
349
575
  useEffect(() => {
576
+ const pending = pendingOperationRef.current;
577
+ if (pending) {
578
+ const shadow = {
579
+ text: valueRef.current,
580
+ resources: mergeResources(
581
+ restoredResourcesRef.current,
582
+ resolveSendExtras(sendExtrasRef.current).resources ?? [],
583
+ ),
584
+ };
585
+ if (
586
+ pending.newerShadow.text !== shadow.text ||
587
+ JSON.stringify(pending.newerShadow.resources) !== JSON.stringify(shadow.resources)
588
+ ) {
589
+ pendingOperationRef.current = updatePendingComposerShadow(
590
+ pendingOperationKey,
591
+ pending,
592
+ shadow,
593
+ );
594
+ }
595
+ return;
596
+ }
350
597
  if (
351
598
  !durableDrafts ||
352
599
  !sessionId ||
@@ -366,6 +613,7 @@ export function useComposer(
366
613
  draftConflict,
367
614
  draftLoading,
368
615
  liveExtrasVersion,
616
+ pendingOperationKey,
369
617
  persistPayload,
370
618
  sending,
371
619
  sessionId,
@@ -375,15 +623,21 @@ export function useComposer(
375
623
  async (delivery: "send" | "steer", explicit?: string): Promise<boolean> => {
376
624
  const ownedTargetKey = targetKey;
377
625
  const ownedGeneration = targetGeneration.current;
626
+ const operationKey = pendingComposerOperationKey(workspaceId, sessionId);
627
+ const pending = pendingOperationRef.current ?? restorePendingComposerOperation(operationKey);
628
+ if (pending && !pendingOperationRef.current) {
629
+ pendingOperationRef.current = pending;
630
+ pendingClientEventId.current = pending.input.clientEventId ?? null;
631
+ }
378
632
  const draftAtSend = value;
379
633
  const rawText = explicit ?? draftAtSend;
380
634
  const hasText = rawText.trim().length > 0;
381
635
  // Resolve the extras once: a file-only message (empty text + ≥1 ready
382
636
  // resource) is legitimate, so we must not bail on empty text alone.
383
- const extras = resolveSendExtras(sendExtrasRef.current);
637
+ const extras = pending ? {} : resolveSendExtras(sendExtrasRef.current);
384
638
  const hasResources = restoredResources.length > 0 || (extras.resources?.length ?? 0) > 0;
385
639
  if (
386
- (!hasText && !hasResources) ||
640
+ (!pending && !hasText && !hasResources) ||
387
641
  !sessionId ||
388
642
  sending ||
389
643
  sendBlockedRef.current?.() === true ||
@@ -391,12 +645,117 @@ export function useComposer(
391
645
  ) {
392
646
  return false;
393
647
  }
394
- // Reuse the clientEventId across retries of the same draft so a
395
- // timeout + resend cannot double-deliver the message.
396
- pendingClientEventId.current ??= generateClientEventId();
648
+
649
+ const clearPending = (): void => {
650
+ pendingOperationRef.current = null;
651
+ pendingClientEventId.current = null;
652
+ forgetPendingComposerOperation(operationKey);
653
+ };
654
+
655
+ const settleAccepted = (operation: PendingComposerOperation): void => {
656
+ clearPending();
657
+ const draftWasUnchanged = valueRef.current === operation.draftAtSend;
658
+ const resourcesWereUnchanged =
659
+ JSON.stringify(restoredResourcesRef.current) ===
660
+ JSON.stringify(operation.resourcesAtSend);
661
+ const previousDraft = draftRef.current;
662
+ if (previousDraft) {
663
+ const cleared = {
664
+ ...previousDraft,
665
+ revision: 0,
666
+ text: "",
667
+ resources: [],
668
+ sourceTurnId: null,
669
+ sourceTurnVersion: null,
670
+ updatedAt: null,
671
+ };
672
+ draftRef.current = cleared;
673
+ setDraft(cleared);
674
+ lastSavedSignature.current = draftSignature(draftPayload(cleared));
675
+ }
676
+ if (resourcesWereUnchanged) {
677
+ restoredResourcesRef.current = [];
678
+ setRestoredResources([]);
679
+ }
680
+ if (operation.clearDraftOnAccept && draftWasUnchanged) {
681
+ valueRef.current = "";
682
+ setValue("");
683
+ }
684
+ onSent?.(operation.input.text, operation.input);
685
+ };
686
+
687
+ const deliver = async (operation: PendingComposerOperation): Promise<void> => {
688
+ if (operation.delivery === "steer") {
689
+ await client.steerMessage(workspaceId, sessionId, operation.input);
690
+ } else {
691
+ await client.sendMessage(workspaceId, sessionId, operation.input);
692
+ }
693
+ };
694
+
397
695
  setSending(true);
398
696
  setError(null);
399
697
  try {
698
+ if (pending) {
699
+ let accepted = false;
700
+ try {
701
+ const events = await client.listEvents(workspaceId, sessionId, {
702
+ includeTypes: ["user.message"],
703
+ limit: 100,
704
+ payloadMode: "none",
705
+ });
706
+ accepted = events.some(
707
+ (event) =>
708
+ event.type === "user.message" &&
709
+ event.clientEventId === pending.input.clientEventId,
710
+ );
711
+ } catch (cause) {
712
+ if (
713
+ targetKeyRef.current === ownedTargetKey &&
714
+ targetGeneration.current === ownedGeneration
715
+ ) {
716
+ setError(asError(cause));
717
+ }
718
+ return false;
719
+ }
720
+ if (
721
+ targetKeyRef.current !== ownedTargetKey ||
722
+ targetGeneration.current !== ownedGeneration
723
+ ) {
724
+ return false;
725
+ }
726
+ if (accepted) {
727
+ settleAccepted(pending);
728
+ return true;
729
+ }
730
+ if (!pending.canRetry) {
731
+ setError(
732
+ new Error(
733
+ "OpenGeni cannot safely retry this uncertain request after remount; reconcile the session before sending again.",
734
+ ),
735
+ );
736
+ return false;
737
+ }
738
+ try {
739
+ await deliver(pending);
740
+ } catch (cause) {
741
+ if (
742
+ targetKeyRef.current === ownedTargetKey &&
743
+ targetGeneration.current === ownedGeneration
744
+ ) {
745
+ setError(asError(cause));
746
+ }
747
+ return false;
748
+ }
749
+ if (
750
+ targetKeyRef.current !== ownedTargetKey ||
751
+ targetGeneration.current !== ownedGeneration
752
+ ) {
753
+ return false;
754
+ }
755
+ settleAccepted(pending);
756
+ return true;
757
+ }
758
+
400
759
  // Trimming is only an emptiness check. A non-blank prompt is persisted
401
760
  // and submitted byte-for-byte, while file-only sends use the same
402
761
  // placeholder for both operations so the server content fence cannot
@@ -420,6 +779,7 @@ export function useComposer(
420
779
  !Object.prototype.hasOwnProperty.call(extras, "tools")
421
780
  ? { ...extras, tools: acknowledgedDraft.tools }
422
781
  : extras;
782
+ pendingClientEventId.current ??= generateClientEventId();
423
783
  const input = composeSendInput(sendText, pendingClientEventId.current, sendExtras, {
424
784
  ...(options.effectiveControl?.controlEtag
425
785
  ? { controlEtag: options.effectiveControl.controlEtag }
@@ -429,10 +789,33 @@ export function useComposer(
429
789
  : {}),
430
790
  resources: mergeResources(restoredResources, extras.resources ?? []),
431
791
  });
432
- if (delivery === "steer") {
433
- await client.steerMessage(workspaceId, sessionId, input);
434
- } else {
435
- await client.sendMessage(workspaceId, sessionId, input);
792
+ const operation: PendingComposerOperation = {
793
+ delivery,
794
+ input,
795
+ draftAtSend,
796
+ resourcesAtSend: [...restoredResources],
797
+ newerShadow: {
798
+ text: draftAtSend,
799
+ resources: mergeResources(restoredResources, extras.resources ?? []),
800
+ },
801
+ clearDraftOnAccept: explicit === undefined,
802
+ canRetry: true,
803
+ };
804
+ pendingOperationRef.current = operation;
805
+ rememberPendingComposerOperation(operationKey, operation);
806
+ try {
807
+ await deliver(operation);
808
+ } catch (cause) {
809
+ if (!isOutcomeUnknownError(cause)) {
810
+ clearPending();
811
+ }
812
+ if (
813
+ targetKeyRef.current === ownedTargetKey &&
814
+ targetGeneration.current === ownedGeneration
815
+ ) {
816
+ setError(asError(cause));
817
+ }
818
+ return false;
436
819
  }
437
820
  if (
438
821
  targetKeyRef.current !== ownedTargetKey ||
@@ -440,42 +823,8 @@ export function useComposer(
440
823
  ) {
441
824
  return false;
442
825
  }
443
- pendingClientEventId.current = null;
444
- const previousDraft = draftRef.current;
445
- if (previousDraft) {
446
- const cleared = {
447
- ...previousDraft,
448
- revision: 0,
449
- text: "",
450
- resources: [],
451
- sourceTurnId: null,
452
- sourceTurnVersion: null,
453
- updatedAt: null,
454
- };
455
- draftRef.current = cleared;
456
- restoredResourcesRef.current = [];
457
- setDraft(cleared);
458
- setRestoredResources([]);
459
- lastSavedSignature.current = draftSignature(draftPayload(cleared));
460
- }
461
- if (explicit === undefined) {
462
- // Clear only the draft that was sent: edits made while the request
463
- // was in flight were never delivered and must survive.
464
- if (valueRef.current === draftAtSend) {
465
- valueRef.current = "";
466
- setValue("");
467
- }
468
- }
469
- onSent?.(sendText, input);
826
+ settleAccepted(operation);
470
827
  return true;
471
- } catch (cause) {
472
- if (
473
- targetKeyRef.current === ownedTargetKey &&
474
- targetGeneration.current === ownedGeneration
475
- ) {
476
- setError(cause instanceof Error ? cause : new Error(String(cause)));
477
- }
478
- return false;
479
828
  } finally {
480
829
  if (
481
830
  targetKeyRef.current === ownedTargetKey &&
@@ -512,6 +861,7 @@ export function useComposer(
512
861
  const hasReadyResources =
513
862
  restoredResources.length > 0 ||
514
863
  (resolveSendExtras(sendExtrasRef.current).resources?.length ?? 0) > 0;
864
+ const hasPendingOperation = pendingOperationRef.current !== null;
515
865
 
516
866
  const pause = useCallback(
517
867
  async (reason?: string): Promise<void> => {
@@ -643,12 +993,22 @@ export function useComposer(
643
993
  const updateValue = useCallback(
644
994
  (next: string) => {
645
995
  if (targetKeyRef.current !== targetKey) return;
646
- pendingClientEventId.current = null;
647
996
  localEditRevision.current += 1;
648
997
  valueRef.current = next;
998
+ pendingOperationRef.current = updatePendingComposerShadow(
999
+ pendingOperationKey,
1000
+ pendingOperationRef.current,
1001
+ {
1002
+ text: next,
1003
+ resources: mergeResources(
1004
+ restoredResourcesRef.current,
1005
+ resolveSendExtras(sendExtrasRef.current).resources ?? [],
1006
+ ),
1007
+ },
1008
+ );
649
1009
  setValue(next);
650
1010
  },
651
- [targetKey],
1011
+ [pendingOperationKey, targetKey],
652
1012
  );
653
1013
 
654
1014
  const removeRestoredResource = useCallback(
@@ -657,9 +1017,17 @@ export function useComposer(
657
1017
  localEditRevision.current += 1;
658
1018
  const next = restoredResourcesRef.current.filter((_, candidate) => candidate !== index);
659
1019
  restoredResourcesRef.current = next;
1020
+ pendingOperationRef.current = updatePendingComposerShadow(
1021
+ pendingOperationKey,
1022
+ pendingOperationRef.current,
1023
+ {
1024
+ text: valueRef.current,
1025
+ resources: mergeResources(next, resolveSendExtras(sendExtrasRef.current).resources ?? []),
1026
+ },
1027
+ );
660
1028
  setRestoredResources(next);
661
1029
  },
662
- [targetKey],
1030
+ [pendingOperationKey, targetKey],
663
1031
  );
664
1032
 
665
1033
  const hasDraftContent = useCallback((): boolean => {
@@ -727,7 +1095,7 @@ export function useComposer(
727
1095
  Boolean(sessionId) &&
728
1096
  !sending &&
729
1097
  sendBlockedRef.current?.() !== true &&
730
- (value.trim().length > 0 || hasReadyResources),
1098
+ (hasPendingOperation || value.trim().length > 0 || hasReadyResources),
731
1099
  pause,
732
1100
  pausing: identityMatches ? pausing : false,
733
1101
  resume,
@@ -805,10 +1173,29 @@ function generateClientEventId(): string {
805
1173
  return globalThis.crypto.randomUUID();
806
1174
  }
807
1175
 
1176
+ function isOutcomeUnknownError(cause: unknown): boolean {
1177
+ return (
1178
+ typeof cause === "object" &&
1179
+ cause !== null &&
1180
+ (cause as { outcomeUnknown?: unknown }).outcomeUnknown === true
1181
+ );
1182
+ }
1183
+
808
1184
  function asError(cause: unknown): Error {
809
1185
  return cause instanceof Error ? cause : new Error(String(cause));
810
1186
  }
811
1187
 
1188
+ function isDraftConflictError(error: Error): boolean {
1189
+ const apiError = error as Partial<OpenGeniApiError>;
1190
+ return (
1191
+ apiError.status === 409 &&
1192
+ apiError.outcomeUnknown === false &&
1193
+ (apiError.code === undefined ||
1194
+ apiError.code === "conflict" ||
1195
+ apiError.code === "idempotency_conflict")
1196
+ );
1197
+ }
1198
+
812
1199
  function draftPayload(draft: ComposerDraft): SaveComposerDraftRequest {
813
1200
  return {
814
1201
  expectedRevision: draft.revision,