@opengeni/react 0.23.0 → 0.25.5

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 (62) hide show
  1. package/dist/{chunk-UQS7OQDE.js → chunk-F4CFWKVI.js} +334 -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-UAHPKLB6.js → chunk-MP6237JB.js} +571 -975
  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-CATATY77.js → chunk-TMH6HZWF.js} +8 -7
  14. package/dist/chunk-TMH6HZWF.js.map +1 -0
  15. package/dist/{chunk-6XUS5VFM.js → chunk-YU5PGUK7.js} +6 -4
  16. package/dist/{chunk-6XUS5VFM.js.map → chunk-YU5PGUK7.js.map} +1 -1
  17. package/dist/{composer-CVm5uoUI.d.ts → composer-DoC4veX1.d.ts} +6 -63
  18. package/dist/composer.d.ts +3 -2
  19. package/dist/composer.js +4 -3
  20. package/dist/fleet-decision-projection-SLNZMS42.js +241 -0
  21. package/dist/fleet-decision-projection-SLNZMS42.js.map +1 -0
  22. package/dist/fleet-decision-row-GGCAT4E4.js +351 -0
  23. package/dist/fleet-decision-row-GGCAT4E4.js.map +1 -0
  24. package/dist/index.d.ts +17 -259
  25. package/dist/index.js +1042 -5058
  26. package/dist/index.js.map +1 -1
  27. package/dist/machines.js +3 -2
  28. package/dist/session-Du_FsrZ1.d.ts +264 -0
  29. package/dist/session-ui-BqQDH7YV.d.ts +183 -0
  30. package/dist/session-ui.d.ts +7 -0
  31. package/dist/session-ui.js +17 -0
  32. package/dist/session-ui.js.map +1 -0
  33. package/dist/session.d.ts +4 -2
  34. package/dist/session.js +26 -9
  35. package/dist/{use-composer-By1wn3MM.d.ts → use-composer-BnEzheSk.d.ts} +8 -2
  36. package/dist/use-file-attachments-C0kpHrs9.d.ts +76 -0
  37. package/dist/use-turn-queue-3rkJwjFv.d.ts +290 -0
  38. package/package.json +6 -2
  39. package/src/components/composer.tsx +7 -6
  40. package/src/components/message-timeline.tsx +94 -54
  41. package/src/hooks/use-composer.ts +470 -73
  42. package/src/hooks/use-file-attachments.ts +96 -3
  43. package/src/hooks/use-goal.ts +23 -5
  44. package/src/hooks/use-session-events.ts +5 -0
  45. package/src/hooks/use-session-lineage.ts +1 -1
  46. package/src/hooks/use-session.ts +9 -6
  47. package/src/hooks/use-workspace-sessions.ts +30 -5
  48. package/src/session-ui.ts +13 -0
  49. package/src/session.ts +15 -0
  50. package/src/timeline/activity-rail.tsx +18 -2
  51. package/src/timeline/fleet-decision-projection.ts +366 -0
  52. package/src/timeline/fleet-decision-row.tsx +412 -0
  53. package/src/timeline/index.ts +2 -0
  54. package/src/timeline/projection.ts +19 -8
  55. package/src/timeline/tool-display-name.ts +16 -0
  56. package/src/timeline/tool-renderers.tsx +1 -1
  57. package/src/timeline/types.ts +68 -1
  58. package/dist/chunk-CATATY77.js.map +0 -1
  59. package/dist/chunk-I3BJZIG5.js.map +0 -1
  60. package/dist/chunk-UAHPKLB6.js.map +0 -1
  61. package/dist/chunk-UQS7OQDE.js.map +0 -1
  62. package/dist/session-Db00TRwl.d.ts +0 -446
@@ -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,
@@ -15,14 +16,20 @@ export type ComposerSendExtras = Omit<SendMessageInput, "text" | "clientEventId"
15
16
 
16
17
  export type UseComposerOptions = EmbeddedSessionClientOverride &
17
18
  SessionEventFeedOptions & {
18
- /** Called with the accepted text after a successful send. */
19
- onSent?: ((text: string) => void) | undefined;
19
+ /** Called with the exact accepted wire input after a successful send. */
20
+ onSent?: ((text: string, input: SendMessageInput) => void) | undefined;
20
21
  /**
21
22
  * Extra message fields (resources, tools, model, reasoningEffort) merged
22
23
  * into every send. A function is evaluated at send time so it can read the
23
24
  * surrounding UI state (attachment pickers, model selectors, ...).
24
25
  */
25
26
  sendExtras?: ComposerSendExtras | (() => ComposerSendExtras) | undefined;
27
+ /**
28
+ * Fail-closed delivery guard evaluated at send time. Attachment hosts use
29
+ * this to preserve unresolved upload cards until the operator waits,
30
+ * retries, or removes them; direct hook callers cannot bypass the UI gate.
31
+ */
32
+ sendBlocked?: (() => boolean) | undefined;
26
33
  /** Latest server-derived workstream control; bound into Send/Steer OCC. */
27
34
  effectiveControl?: EffectiveSessionControl | null | undefined;
28
35
  /** Apply durable model/tool/reasoning settings in the host's controlled UI. */
@@ -31,6 +38,184 @@ export type UseComposerOptions = EmbeddedSessionClientOverride &
31
38
  draftPersistence?: "durable" | "disabled" | undefined;
32
39
  };
33
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
+
34
219
  export type ComposerState = {
35
220
  value: string;
36
221
  setValue: (value: string) => void;
@@ -78,7 +263,10 @@ export function useComposer(
78
263
  const { client, workspaceId, registerSessionReconciler } = useEmbeddedSession(options);
79
264
  const durableDrafts = options.draftPersistence !== "disabled";
80
265
  const targetKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${durableDrafts ? "durable" : "disabled"}`;
81
- 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 ?? "");
82
270
  // Keep rendered state behind the committed target identity for one frame:
83
271
  // a parent may switch sessionId without remounting this public hook.
84
272
  const [stateTargetKey, setStateTargetKey] = useState(targetKey);
@@ -90,22 +278,37 @@ export function useComposer(
90
278
  const [draftLoading, setDraftLoading] = useState(Boolean(sessionId) && durableDrafts);
91
279
  const [draftSaving, setDraftSaving] = useState(false);
92
280
  const [draftConflict, setDraftConflict] = useState<Error | null>(null);
93
- const [restoredResources, setRestoredResources] = useState<ResourceRef[]>([]);
94
- const pendingClientEventId = useRef<string | null>(null);
95
- 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 ?? "");
96
289
  const draftRef = useRef<ComposerDraft | null>(null);
97
- const restoredResourcesRef = useRef<ResourceRef[]>([]);
98
- const localEditRevision = useRef(0);
290
+ const restoredResourcesRef = useRef<ResourceRef[]>(initialShadow?.resources ?? []);
291
+ const localEditRevision = useRef(initialShadow ? 1 : 0);
99
292
  const targetGeneration = useRef(0);
100
293
  const draftReadGeneration = useRef(0);
101
294
  const lastSavedSignature = useRef<string | null>(null);
102
295
  const saveChain = useRef<Promise<void>>(Promise.resolve());
103
296
  const onSent = options.onSent;
104
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]);
105
306
  // Read through a ref so a new extras closure (created every render by
106
307
  // callers passing inline functions) does not invalidate `send`.
107
308
  const sendExtrasRef = useRef(options.sendExtras);
108
309
  sendExtrasRef.current = options.sendExtras;
310
+ const sendBlockedRef = useRef(options.sendBlocked);
311
+ sendBlockedRef.current = options.sendBlocked;
109
312
  const liveExtrasVersion = JSON.stringify(resolveSendExtras(options.sendExtras));
110
313
 
111
314
  // A composer is bound to one session: switching targets must not leak the
@@ -116,17 +319,19 @@ export function useComposer(
116
319
  targetKeyRef.current = targetKey;
117
320
  targetGeneration.current += 1;
118
321
  draftReadGeneration.current += 1;
119
- pendingClientEventId.current = null;
120
- localEditRevision.current = 0;
121
- 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 ?? "";
122
327
  draftRef.current = null;
123
- restoredResourcesRef.current = [];
328
+ restoredResourcesRef.current = shadow?.resources ?? [];
124
329
  lastSavedSignature.current = null;
125
330
  // Old saves may still be awaiting the network. Their generation fence
126
331
  // prevents settlement, and a fresh chain avoids blocking this target.
127
332
  saveChain.current = Promise.resolve();
128
333
  setStateTargetKey(targetKey);
129
- setValue("");
334
+ setValue(shadow?.text ?? "");
130
335
  setSending(false);
131
336
  setPausing(false);
132
337
  setResuming(false);
@@ -135,14 +340,13 @@ export function useComposer(
135
340
  setDraftLoading(Boolean(sessionId) && durableDrafts);
136
341
  setDraftSaving(false);
137
342
  setDraftConflict(null);
138
- setRestoredResources([]);
139
- }, [durableDrafts, sessionId, targetKey]);
343
+ setRestoredResources(shadow?.resources ?? []);
344
+ }, [durableDrafts, pendingOperationKey, sessionId, targetKey]);
140
345
 
141
346
  const applyDraft = useCallback(
142
347
  (next: ComposerDraft): void => {
143
348
  if (targetKeyRef.current !== targetKey) return;
144
349
  if (!durableDrafts) {
145
- pendingClientEventId.current = null;
146
350
  localEditRevision.current += 1;
147
351
  valueRef.current = next.text;
148
352
  restoredResourcesRef.current = next.resources;
@@ -151,6 +355,17 @@ export function useComposer(
151
355
  setDraft(null);
152
356
  setValue(next.text);
153
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
+ );
154
369
  setDraftConflict(null);
155
370
  return;
156
371
  }
@@ -159,14 +374,24 @@ export function useComposer(
159
374
  restoredResourcesRef.current = next.resources;
160
375
  lastSavedSignature.current = draftSignature(draftPayload(next));
161
376
  localEditRevision.current += 1;
162
- pendingClientEventId.current = null;
163
377
  setDraft(next);
164
378
  setValue(next.text);
165
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
+ );
166
391
  setDraftConflict(null);
167
- onDraftApplied?.(next);
392
+ onDraftAppliedRef.current?.(next);
168
393
  },
169
- [durableDrafts, onDraftApplied, targetKey],
394
+ [durableDrafts, pendingOperationKey, targetKey],
170
395
  );
171
396
 
172
397
  const loadDraft = useCallback(
@@ -210,7 +435,16 @@ export function useComposer(
210
435
  draftRef.current = fetched;
211
436
  setDraft(fetched);
212
437
  setDraftConflict(null);
213
- 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 (
214
448
  replaceLocal ||
215
449
  (!localWasDirtyAtStart && localAtStart === localEditRevision.current)
216
450
  ) {
@@ -219,7 +453,7 @@ export function useComposer(
219
453
  lastSavedSignature.current = draftSignature(draftPayload(fetched));
220
454
  setValue(fetched.text);
221
455
  setRestoredResources(fetched.resources);
222
- onDraftApplied?.(fetched);
456
+ onDraftAppliedRef.current?.(fetched);
223
457
  }
224
458
  }
225
459
  } catch (cause) {
@@ -240,7 +474,7 @@ export function useComposer(
240
474
  }
241
475
  }
242
476
  },
243
- [client, durableDrafts, onDraftApplied, sessionId, targetKey, workspaceId],
477
+ [client, durableDrafts, sessionId, targetKey, workspaceId],
244
478
  );
245
479
 
246
480
  useEffect(() => {
@@ -317,7 +551,7 @@ export function useComposer(
317
551
  targetGeneration.current === ownedGeneration
318
552
  ) {
319
553
  const problem = asError(cause);
320
- setDraftConflict(problem);
554
+ if (isDraftConflictError(problem)) setDraftConflict(problem);
321
555
  setError(problem);
322
556
  }
323
557
  } finally {
@@ -339,6 +573,27 @@ export function useComposer(
339
573
  // Private durable autosave. A newer local edit is never replaced by an older
340
574
  // response; saves serialize and each reads the latest acknowledged revision.
341
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
+ }
342
597
  if (
343
598
  !durableDrafts ||
344
599
  !sessionId ||
@@ -358,6 +613,7 @@ export function useComposer(
358
613
  draftConflict,
359
614
  draftLoading,
360
615
  liveExtrasVersion,
616
+ pendingOperationKey,
361
617
  persistPayload,
362
618
  sending,
363
619
  sessionId,
@@ -367,27 +623,139 @@ export function useComposer(
367
623
  async (delivery: "send" | "steer", explicit?: string): Promise<boolean> => {
368
624
  const ownedTargetKey = targetKey;
369
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
+ }
370
632
  const draftAtSend = value;
371
633
  const rawText = explicit ?? draftAtSend;
372
634
  const hasText = rawText.trim().length > 0;
373
635
  // Resolve the extras once: a file-only message (empty text + ≥1 ready
374
636
  // resource) is legitimate, so we must not bail on empty text alone.
375
- const extras = resolveSendExtras(sendExtrasRef.current);
637
+ const extras = pending ? {} : resolveSendExtras(sendExtrasRef.current);
376
638
  const hasResources = restoredResources.length > 0 || (extras.resources?.length ?? 0) > 0;
377
639
  if (
378
- (!hasText && !hasResources) ||
640
+ (!pending && !hasText && !hasResources) ||
379
641
  !sessionId ||
380
642
  sending ||
643
+ sendBlockedRef.current?.() === true ||
381
644
  targetKeyRef.current !== ownedTargetKey
382
645
  ) {
383
646
  return false;
384
647
  }
385
- // Reuse the clientEventId across retries of the same draft so a
386
- // timeout + resend cannot double-deliver the message.
387
- 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
+
388
695
  setSending(true);
389
696
  setError(null);
390
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
+
391
759
  // Trimming is only an emptiness check. A non-blank prompt is persisted
392
760
  // and submitted byte-for-byte, while file-only sends use the same
393
761
  // placeholder for both operations so the server content fence cannot
@@ -411,6 +779,7 @@ export function useComposer(
411
779
  !Object.prototype.hasOwnProperty.call(extras, "tools")
412
780
  ? { ...extras, tools: acknowledgedDraft.tools }
413
781
  : extras;
782
+ pendingClientEventId.current ??= generateClientEventId();
414
783
  const input = composeSendInput(sendText, pendingClientEventId.current, sendExtras, {
415
784
  ...(options.effectiveControl?.controlEtag
416
785
  ? { controlEtag: options.effectiveControl.controlEtag }
@@ -420,10 +789,33 @@ export function useComposer(
420
789
  : {}),
421
790
  resources: mergeResources(restoredResources, extras.resources ?? []),
422
791
  });
423
- if (delivery === "steer") {
424
- await client.steerMessage(workspaceId, sessionId, input);
425
- } else {
426
- 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;
427
819
  }
428
820
  if (
429
821
  targetKeyRef.current !== ownedTargetKey ||
@@ -431,42 +823,8 @@ export function useComposer(
431
823
  ) {
432
824
  return false;
433
825
  }
434
- pendingClientEventId.current = null;
435
- const previousDraft = draftRef.current;
436
- if (previousDraft) {
437
- const cleared = {
438
- ...previousDraft,
439
- revision: 0,
440
- text: "",
441
- resources: [],
442
- sourceTurnId: null,
443
- sourceTurnVersion: null,
444
- updatedAt: null,
445
- };
446
- draftRef.current = cleared;
447
- restoredResourcesRef.current = [];
448
- setDraft(cleared);
449
- setRestoredResources([]);
450
- lastSavedSignature.current = draftSignature(draftPayload(cleared));
451
- }
452
- if (explicit === undefined) {
453
- // Clear only the draft that was sent: edits made while the request
454
- // was in flight were never delivered and must survive.
455
- if (valueRef.current === draftAtSend) {
456
- valueRef.current = "";
457
- setValue("");
458
- }
459
- }
460
- onSent?.(sendText);
826
+ settleAccepted(operation);
461
827
  return true;
462
- } catch (cause) {
463
- if (
464
- targetKeyRef.current === ownedTargetKey &&
465
- targetGeneration.current === ownedGeneration
466
- ) {
467
- setError(cause instanceof Error ? cause : new Error(String(cause)));
468
- }
469
- return false;
470
828
  } finally {
471
829
  if (
472
830
  targetKeyRef.current === ownedTargetKey &&
@@ -498,11 +856,12 @@ export function useComposer(
498
856
  // A send is possible with non-empty text OR with ≥1 attached resource (a
499
857
  // file-only message). Resources ride in `sendExtras`, so we resolve them here
500
858
  // — keeping useComposer attachment-agnostic while still lighting up the send
501
- // affordance the moment a file is ready. ChatComposer additionally gates this
502
- // on its `attachments.uploading` flag so a message never departs mid-upload.
859
+ // affordance the moment a file is ready. Attachment hosts bind `sendBlocked`
860
+ // to unresolved uploads so direct send()/steer() calls fail closed too.
503
861
  const hasReadyResources =
504
862
  restoredResources.length > 0 ||
505
863
  (resolveSendExtras(sendExtrasRef.current).resources?.length ?? 0) > 0;
864
+ const hasPendingOperation = pendingOperationRef.current !== null;
506
865
 
507
866
  const pause = useCallback(
508
867
  async (reason?: string): Promise<void> => {
@@ -634,12 +993,22 @@ export function useComposer(
634
993
  const updateValue = useCallback(
635
994
  (next: string) => {
636
995
  if (targetKeyRef.current !== targetKey) return;
637
- pendingClientEventId.current = null;
638
996
  localEditRevision.current += 1;
639
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
+ );
640
1009
  setValue(next);
641
1010
  },
642
- [targetKey],
1011
+ [pendingOperationKey, targetKey],
643
1012
  );
644
1013
 
645
1014
  const removeRestoredResource = useCallback(
@@ -648,9 +1017,17 @@ export function useComposer(
648
1017
  localEditRevision.current += 1;
649
1018
  const next = restoredResourcesRef.current.filter((_, candidate) => candidate !== index);
650
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
+ );
651
1028
  setRestoredResources(next);
652
1029
  },
653
- [targetKey],
1030
+ [pendingOperationKey, targetKey],
654
1031
  );
655
1032
 
656
1033
  const hasDraftContent = useCallback((): boolean => {
@@ -717,7 +1094,8 @@ export function useComposer(
717
1094
  identityMatches &&
718
1095
  Boolean(sessionId) &&
719
1096
  !sending &&
720
- (value.trim().length > 0 || hasReadyResources),
1097
+ sendBlockedRef.current?.() !== true &&
1098
+ (hasPendingOperation || value.trim().length > 0 || hasReadyResources),
721
1099
  pause,
722
1100
  pausing: identityMatches ? pausing : false,
723
1101
  resume,
@@ -795,10 +1173,29 @@ function generateClientEventId(): string {
795
1173
  return globalThis.crypto.randomUUID();
796
1174
  }
797
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
+
798
1184
  function asError(cause: unknown): Error {
799
1185
  return cause instanceof Error ? cause : new Error(String(cause));
800
1186
  }
801
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
+
802
1199
  function draftPayload(draft: ComposerDraft): SaveComposerDraftRequest {
803
1200
  return {
804
1201
  expectedRevision: draft.revision,