@opengeni/react 0.15.0 → 0.23.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 (103) hide show
  1. package/README.md +110 -10
  2. package/dist/{chunk-TOJR776I.js → chunk-6XUS5VFM.js} +45 -369
  3. package/dist/chunk-6XUS5VFM.js.map +1 -0
  4. package/dist/chunk-CATATY77.js +2393 -0
  5. package/dist/chunk-CATATY77.js.map +1 -0
  6. package/dist/chunk-I3BJZIG5.js +439 -0
  7. package/dist/chunk-I3BJZIG5.js.map +1 -0
  8. package/dist/chunk-TK7G6XLT.js +18 -0
  9. package/dist/chunk-TK7G6XLT.js.map +1 -0
  10. package/dist/chunk-UAHPKLB6.js +2237 -0
  11. package/dist/chunk-UAHPKLB6.js.map +1 -0
  12. package/dist/chunk-UQS7OQDE.js +591 -0
  13. package/dist/chunk-UQS7OQDE.js.map +1 -0
  14. package/dist/chunk-YDNWMKXO.js +102 -0
  15. package/dist/chunk-YDNWMKXO.js.map +1 -0
  16. package/dist/composer-CVm5uoUI.d.ts +585 -0
  17. package/dist/composer.d.ts +6 -0
  18. package/dist/composer.js +56 -0
  19. package/dist/composer.js.map +1 -0
  20. package/dist/index.d.ts +1288 -1785
  21. package/dist/index.js +7140 -8817
  22. package/dist/index.js.map +1 -1
  23. package/dist/machines.d.ts +412 -3
  24. package/dist/machines.js +3 -1
  25. package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
  26. package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
  27. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
  28. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
  29. package/dist/queue-surface-implementation-3UW3MIIR.js +890 -0
  30. package/dist/queue-surface-implementation-3UW3MIIR.js.map +1 -0
  31. package/dist/session-Db00TRwl.d.ts +446 -0
  32. package/dist/session-context-rgrfElMl.d.ts +58 -0
  33. package/dist/session.d.ts +4 -0
  34. package/dist/session.js +54 -0
  35. package/dist/session.js.map +1 -0
  36. package/dist/use-composer-By1wn3MM.d.ts +99 -0
  37. package/package.json +20 -4
  38. package/src/approvals.ts +5 -3
  39. package/src/client.ts +44 -0
  40. package/src/components/approval-surface.tsx +198 -0
  41. package/src/components/chat-composer.tsx +94 -993
  42. package/src/components/code-editor.tsx +104 -26
  43. package/src/components/command-palette.tsx +8 -2
  44. package/src/components/composer-transcription-control.tsx +211 -0
  45. package/src/components/composer.tsx +1536 -0
  46. package/src/components/desktop-viewer.tsx +3 -3
  47. package/src/components/enrollment-consent.tsx +2 -2
  48. package/src/components/file-browser.tsx +367 -29
  49. package/src/components/human-input-form.tsx +417 -0
  50. package/src/components/machine-card.tsx +1 -1
  51. package/src/components/machines-dashboard.tsx +3 -3
  52. package/src/components/message-timeline.tsx +205 -25
  53. package/src/components/model-picker.tsx +12 -3
  54. package/src/components/pierre-diff.tsx +32 -6
  55. package/src/components/queue-draft-policy.ts +18 -0
  56. package/src/components/queue-surface-implementation.tsx +1037 -0
  57. package/src/components/queue-surface-state.tsx +94 -0
  58. package/src/components/queue-surface.tsx +42 -559
  59. package/src/components/sandbox-files.tsx +138 -14
  60. package/src/components/sandbox-terminal.tsx +8 -2
  61. package/src/components/sandbox-workspace.tsx +339 -119
  62. package/src/components/workbench-changes.tsx +84 -33
  63. package/src/components/workspace-dock.tsx +188 -18
  64. package/src/composer.ts +60 -0
  65. package/src/hooks/internal.ts +115 -34
  66. package/src/hooks/use-composer.ts +385 -104
  67. package/src/hooks/use-human-input.ts +131 -0
  68. package/src/hooks/use-machine-chip.ts +2 -2
  69. package/src/hooks/use-machines.ts +27 -11
  70. package/src/hooks/use-sandbox-files.ts +310 -62
  71. package/src/hooks/use-sandbox-git.ts +154 -40
  72. package/src/hooks/use-sandbox-terminal.ts +28 -6
  73. package/src/hooks/use-session-capabilities.ts +38 -9
  74. package/src/hooks/use-session-control.ts +37 -8
  75. package/src/hooks/use-session-events.ts +522 -48
  76. package/src/hooks/use-session-mcp-approval-policy.ts +161 -0
  77. package/src/hooks/use-slash-commands.ts +38 -38
  78. package/src/hooks/use-transcription.ts +757 -0
  79. package/src/hooks/use-turn-queue.ts +134 -66
  80. package/src/hooks/use-windowed-sections.ts +2 -2
  81. package/src/hooks/use-workspace-capture.ts +146 -40
  82. package/src/hooks/use-workspace-edit.ts +50 -14
  83. package/src/hooks/use-workspace-sessions.ts +3 -0
  84. package/src/human-input.ts +72 -0
  85. package/src/index.ts +85 -6
  86. package/src/lib/noto-sans-arabic-loader.ts +21 -0
  87. package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
  88. package/src/lib/use-portal-token-style.ts +51 -0
  89. package/src/lib/use-unicode-fonts.ts +57 -0
  90. package/src/provider.tsx +4 -69
  91. package/src/session-context.ts +160 -0
  92. package/src/session.ts +89 -0
  93. package/src/timeline/parsers.ts +35 -0
  94. package/src/timeline/projection.ts +12 -4
  95. package/src/timeline/tool-renderers.tsx +41 -0
  96. package/src/timeline/turn-summary.tsx +2 -2
  97. package/src/timeline/types.ts +10 -9
  98. package/styles/index.css +56 -0
  99. package/styles/index.d.ts +2 -0
  100. package/styles/tokens.css +11 -6
  101. package/styles/tokens.d.ts +2 -0
  102. package/dist/chunk-TOJR776I.js.map +0 -1
  103. package/dist/machines-BpdwuQcD.d.ts +0 -449
@@ -7,13 +7,13 @@ import type {
7
7
  SendMessageInput,
8
8
  SessionEvent,
9
9
  } from "@opengeni/sdk";
10
- import { useCallback, useEffect, useRef, useState } from "react";
11
- import { useOpenGeni, type ClientOverride } from "../provider";
10
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
11
+ import { useEmbeddedSession, type EmbeddedSessionClientOverride } from "../session-context";
12
12
  import { useSessionEventTrigger, type SessionEventFeedOptions } from "./internal";
13
13
 
14
14
  export type ComposerSendExtras = Omit<SendMessageInput, "text" | "clientEventId">;
15
15
 
16
- export type UseComposerOptions = ClientOverride &
16
+ export type UseComposerOptions = EmbeddedSessionClientOverride &
17
17
  SessionEventFeedOptions & {
18
18
  /** Called with the accepted text after a successful send. */
19
19
  onSent?: ((text: string) => void) | undefined;
@@ -27,11 +27,15 @@ export type UseComposerOptions = ClientOverride &
27
27
  effectiveControl?: EffectiveSessionControl | null | undefined;
28
28
  /** Apply durable model/tool/reasoning settings in the host's controlled UI. */
29
29
  onDraftApplied?: ((draft: ComposerDraft) => void) | undefined;
30
+ /** Disable remote composer-draft reads and writes for embedded hosts. */
31
+ draftPersistence?: "durable" | "disabled" | undefined;
30
32
  };
31
33
 
32
34
  export type ComposerState = {
33
35
  value: string;
34
36
  setValue: (value: string) => void;
37
+ /** Read the current draft synchronously before a destructive replacement. */
38
+ hasDraftContent: () => boolean;
35
39
  /** Append the draft behind prompts already visible in the queue. */
36
40
  send: (text?: string) => Promise<boolean>;
37
41
  /** Supersede current direction with the draft. */
@@ -49,6 +53,8 @@ export type ComposerState = {
49
53
  draftLoading: boolean;
50
54
  draftSaving: boolean;
51
55
  draftConflict: Error | null;
56
+ /** Whether this controller owns a durable server-side draft. */
57
+ draftPersistence?: "durable" | "disabled" | undefined;
52
58
  /** Apply an atomic queue Edit checkout without a second read. */
53
59
  applyDraft: (draft: ComposerDraft) => void;
54
60
  reloadDraft: () => Promise<void>;
@@ -69,21 +75,29 @@ export function useComposer(
69
75
  sessionId: string | null | undefined,
70
76
  options: UseComposerOptions = {},
71
77
  ): ComposerState {
72
- const { client, workspaceId, registerSessionReconciler } = useOpenGeni(options);
78
+ const { client, workspaceId, registerSessionReconciler } = useEmbeddedSession(options);
79
+ const durableDrafts = options.draftPersistence !== "disabled";
80
+ const targetKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${durableDrafts ? "durable" : "disabled"}`;
73
81
  const [value, setValue] = useState("");
82
+ // Keep rendered state behind the committed target identity for one frame:
83
+ // a parent may switch sessionId without remounting this public hook.
84
+ const [stateTargetKey, setStateTargetKey] = useState(targetKey);
74
85
  const [sending, setSending] = useState(false);
75
86
  const [pausing, setPausing] = useState(false);
76
87
  const [resuming, setResuming] = useState(false);
77
88
  const [error, setError] = useState<Error | null>(null);
78
89
  const [draft, setDraft] = useState<ComposerDraft | null>(null);
79
- const [draftLoading, setDraftLoading] = useState(Boolean(sessionId));
90
+ const [draftLoading, setDraftLoading] = useState(Boolean(sessionId) && durableDrafts);
80
91
  const [draftSaving, setDraftSaving] = useState(false);
81
92
  const [draftConflict, setDraftConflict] = useState<Error | null>(null);
82
93
  const [restoredResources, setRestoredResources] = useState<ResourceRef[]>([]);
83
94
  const pendingClientEventId = useRef<string | null>(null);
95
+ const valueRef = useRef("");
84
96
  const draftRef = useRef<ComposerDraft | null>(null);
97
+ const restoredResourcesRef = useRef<ResourceRef[]>([]);
85
98
  const localEditRevision = useRef(0);
86
99
  const targetGeneration = useRef(0);
100
+ const draftReadGeneration = useRef(0);
87
101
  const lastSavedSignature = useRef<string | null>(null);
88
102
  const saveChain = useRef<Promise<void>>(Promise.resolve());
89
103
  const onSent = options.onSent;
@@ -96,27 +110,53 @@ export function useComposer(
96
110
 
97
111
  // A composer is bound to one session: switching targets must not leak the
98
112
  // previous session's draft, error, or retry idempotency key.
99
- const targetKey = `${workspaceId}\u0000${sessionId ?? ""}`;
100
113
  const targetKeyRef = useRef(targetKey);
101
- useEffect(() => {
102
- if (targetKeyRef.current !== targetKey) {
103
- targetKeyRef.current = targetKey;
104
- targetGeneration.current += 1;
105
- pendingClientEventId.current = null;
106
- localEditRevision.current = 0;
107
- draftRef.current = null;
108
- lastSavedSignature.current = null;
109
- setValue("");
110
- setError(null);
111
- setDraft(null);
112
- setDraftConflict(null);
113
- setRestoredResources([]);
114
- }
115
- }, [targetKey]);
114
+ useLayoutEffect(() => {
115
+ if (targetKeyRef.current === targetKey) return;
116
+ targetKeyRef.current = targetKey;
117
+ targetGeneration.current += 1;
118
+ draftReadGeneration.current += 1;
119
+ pendingClientEventId.current = null;
120
+ localEditRevision.current = 0;
121
+ valueRef.current = "";
122
+ draftRef.current = null;
123
+ restoredResourcesRef.current = [];
124
+ lastSavedSignature.current = null;
125
+ // Old saves may still be awaiting the network. Their generation fence
126
+ // prevents settlement, and a fresh chain avoids blocking this target.
127
+ saveChain.current = Promise.resolve();
128
+ setStateTargetKey(targetKey);
129
+ setValue("");
130
+ setSending(false);
131
+ setPausing(false);
132
+ setResuming(false);
133
+ setError(null);
134
+ setDraft(null);
135
+ setDraftLoading(Boolean(sessionId) && durableDrafts);
136
+ setDraftSaving(false);
137
+ setDraftConflict(null);
138
+ setRestoredResources([]);
139
+ }, [durableDrafts, sessionId, targetKey]);
116
140
 
117
141
  const applyDraft = useCallback(
118
142
  (next: ComposerDraft): void => {
143
+ if (targetKeyRef.current !== targetKey) return;
144
+ if (!durableDrafts) {
145
+ pendingClientEventId.current = null;
146
+ localEditRevision.current += 1;
147
+ valueRef.current = next.text;
148
+ restoredResourcesRef.current = next.resources;
149
+ draftRef.current = null;
150
+ lastSavedSignature.current = null;
151
+ setDraft(null);
152
+ setValue(next.text);
153
+ setRestoredResources(next.resources);
154
+ setDraftConflict(null);
155
+ return;
156
+ }
157
+ valueRef.current = next.text;
119
158
  draftRef.current = next;
159
+ restoredResourcesRef.current = next.resources;
120
160
  lastSavedSignature.current = draftSignature(draftPayload(next));
121
161
  localEditRevision.current += 1;
122
162
  pendingClientEventId.current = null;
@@ -126,47 +166,94 @@ export function useComposer(
126
166
  setDraftConflict(null);
127
167
  onDraftApplied?.(next);
128
168
  },
129
- [onDraftApplied],
169
+ [durableDrafts, onDraftApplied, targetKey],
130
170
  );
131
171
 
132
172
  const loadDraft = useCallback(
133
173
  async (replaceLocal: boolean): Promise<void> => {
134
- if (!sessionId) return;
174
+ if (targetKeyRef.current !== targetKey) return;
175
+ if (!sessionId || !durableDrafts) {
176
+ setDraftLoading(false);
177
+ return;
178
+ }
135
179
  const generation = targetGeneration.current;
180
+ const readTicket = ++draftReadGeneration.current;
136
181
  const localAtStart = localEditRevision.current;
182
+ const baseAtStart = draftRef.current;
183
+ const extrasAtStart = resolveSendExtras(sendExtrasRef.current);
184
+ const localSignatureAtStart = baseAtStart
185
+ ? draftSignature(
186
+ composerDraftPayload(
187
+ baseAtStart,
188
+ valueRef.current,
189
+ restoredResourcesRef.current,
190
+ extrasAtStart,
191
+ ),
192
+ )
193
+ : null;
194
+ const localWasDirtyAtStart =
195
+ localSignatureAtStart === null
196
+ ? localAtStart !== 0
197
+ : localSignatureAtStart !== lastSavedSignature.current;
137
198
  setDraftLoading(true);
138
199
  try {
139
200
  const fetched = await client.getComposerDraft(workspaceId, sessionId);
140
- if (generation !== targetGeneration.current) return;
141
- draftRef.current = fetched;
142
- setDraft(fetched);
143
- setDraftConflict(null);
144
- if (replaceLocal || localAtStart === localEditRevision.current) {
145
- lastSavedSignature.current = draftSignature(draftPayload(fetched));
146
- setValue(fetched.text);
147
- setRestoredResources(fetched.resources);
148
- onDraftApplied?.(fetched);
201
+ if (
202
+ generation !== targetGeneration.current ||
203
+ targetKeyRef.current !== targetKey ||
204
+ readTicket !== draftReadGeneration.current
205
+ ) {
206
+ return;
207
+ }
208
+ const currentRevision = draftRef.current?.revision ?? -1;
209
+ if (fetched.revision >= currentRevision) {
210
+ draftRef.current = fetched;
211
+ setDraft(fetched);
212
+ setDraftConflict(null);
213
+ if (
214
+ replaceLocal ||
215
+ (!localWasDirtyAtStart && localAtStart === localEditRevision.current)
216
+ ) {
217
+ valueRef.current = fetched.text;
218
+ restoredResourcesRef.current = fetched.resources;
219
+ lastSavedSignature.current = draftSignature(draftPayload(fetched));
220
+ setValue(fetched.text);
221
+ setRestoredResources(fetched.resources);
222
+ onDraftApplied?.(fetched);
223
+ }
149
224
  }
150
225
  } catch (cause) {
151
- if (generation === targetGeneration.current) setError(asError(cause));
226
+ if (
227
+ generation === targetGeneration.current &&
228
+ targetKeyRef.current === targetKey &&
229
+ readTicket === draftReadGeneration.current
230
+ ) {
231
+ setError(asError(cause));
232
+ }
152
233
  } finally {
153
- if (generation === targetGeneration.current) setDraftLoading(false);
234
+ if (
235
+ generation === targetGeneration.current &&
236
+ targetKeyRef.current === targetKey &&
237
+ readTicket === draftReadGeneration.current
238
+ ) {
239
+ setDraftLoading(false);
240
+ }
154
241
  }
155
242
  },
156
- [client, onDraftApplied, sessionId, workspaceId],
243
+ [client, durableDrafts, onDraftApplied, sessionId, targetKey, workspaceId],
157
244
  );
158
245
 
159
246
  useEffect(() => {
160
- if (!sessionId) {
247
+ if (!sessionId || !durableDrafts) {
161
248
  setDraftLoading(false);
162
249
  return;
163
250
  }
164
251
  void loadDraft(false);
165
- }, [loadDraft, sessionId]);
252
+ }, [durableDrafts, loadDraft, sessionId]);
166
253
  useEffect(() => {
167
- if (!sessionId) return;
254
+ if (!sessionId || !durableDrafts) return;
168
255
  return registerSessionReconciler(sessionId, "composer", async () => await loadDraft(false));
169
- }, [loadDraft, registerSessionReconciler, sessionId]);
256
+ }, [durableDrafts, loadDraft, registerSessionReconciler, sessionId]);
170
257
  useSessionEventTrigger(
171
258
  client,
172
259
  workspaceId,
@@ -174,30 +261,34 @@ export function useComposer(
174
261
  isComposerDraftEvent,
175
262
  () => void loadDraft(false),
176
263
  {
177
- enabled: Boolean(sessionId),
264
+ enabled: Boolean(sessionId) && durableDrafts,
178
265
  ...(options.events !== undefined ? { events: options.events } : {}),
179
266
  },
180
267
  );
181
268
 
182
269
  const currentDraftPayload = useCallback((): SaveComposerDraftRequest | null => {
270
+ if (!durableDrafts || targetKeyRef.current !== targetKey) return null;
183
271
  const base = draftRef.current;
184
272
  if (!base) return null;
185
273
  const extras = resolveSendExtras(sendExtrasRef.current);
186
- return {
187
- expectedRevision: base.revision,
188
- text: value,
189
- resources: mergeResources(restoredResources, extras.resources ?? []),
190
- tools: extras.tools ?? base.tools,
191
- model: extras.model ?? base.model,
192
- reasoningEffort: extras.reasoningEffort ?? base.reasoningEffort,
193
- };
194
- }, [restoredResources, value]);
274
+ return composerDraftPayload(base, value, restoredResources, extras);
275
+ }, [durableDrafts, restoredResources, targetKey, value]);
195
276
 
196
277
  const persistPayload = useCallback(
197
278
  async (payload: SaveComposerDraftRequest): Promise<boolean> => {
198
- if (!sessionId) return false;
279
+ const ownedTargetKey = targetKey;
280
+ const ownedGeneration = targetGeneration.current;
281
+ if (!sessionId || !durableDrafts || targetKeyRef.current !== ownedTargetKey) {
282
+ return false;
283
+ }
199
284
  let success = false;
200
285
  const run = async () => {
286
+ if (
287
+ targetKeyRef.current !== ownedTargetKey ||
288
+ targetGeneration.current !== ownedGeneration
289
+ ) {
290
+ return;
291
+ }
201
292
  const current = draftRef.current;
202
293
  if (!current) return;
203
294
  const request = { ...payload, expectedRevision: current.revision };
@@ -209,36 +300,61 @@ export function useComposer(
209
300
  setDraftSaving(true);
210
301
  try {
211
302
  const saved = await client.saveComposerDraft(workspaceId, sessionId, request);
303
+ if (
304
+ targetKeyRef.current !== ownedTargetKey ||
305
+ targetGeneration.current !== ownedGeneration
306
+ ) {
307
+ return;
308
+ }
212
309
  draftRef.current = saved;
213
310
  setDraft(saved);
214
311
  lastSavedSignature.current = signature;
215
312
  setDraftConflict(null);
216
313
  success = true;
217
314
  } catch (cause) {
218
- const problem = asError(cause);
219
- setDraftConflict(problem);
220
- setError(problem);
315
+ if (
316
+ targetKeyRef.current === ownedTargetKey &&
317
+ targetGeneration.current === ownedGeneration
318
+ ) {
319
+ const problem = asError(cause);
320
+ setDraftConflict(problem);
321
+ setError(problem);
322
+ }
221
323
  } finally {
222
- setDraftSaving(false);
324
+ if (
325
+ targetKeyRef.current === ownedTargetKey &&
326
+ targetGeneration.current === ownedGeneration
327
+ ) {
328
+ setDraftSaving(false);
329
+ }
223
330
  }
224
331
  };
225
332
  saveChain.current = saveChain.current.then(run, run);
226
333
  await saveChain.current;
227
334
  return success;
228
335
  },
229
- [client, sessionId, workspaceId],
336
+ [client, durableDrafts, sessionId, targetKey, workspaceId],
230
337
  );
231
338
 
232
339
  // Private durable autosave. A newer local edit is never replaced by an older
233
340
  // response; saves serialize and each reads the latest acknowledged revision.
234
341
  useEffect(() => {
235
- if (!sessionId || draftLoading || sending || !draftRef.current || draftConflict) return;
342
+ if (
343
+ !durableDrafts ||
344
+ !sessionId ||
345
+ draftLoading ||
346
+ sending ||
347
+ !draftRef.current ||
348
+ draftConflict
349
+ )
350
+ return;
236
351
  const payload = currentDraftPayload();
237
352
  if (!payload || draftSignature(payload) === lastSavedSignature.current) return;
238
353
  const timer = window.setTimeout(() => void persistPayload(payload), 500);
239
354
  return () => window.clearTimeout(timer);
240
355
  }, [
241
356
  currentDraftPayload,
357
+ durableDrafts,
242
358
  draftConflict,
243
359
  draftLoading,
244
360
  liveExtrasVersion,
@@ -249,13 +365,21 @@ export function useComposer(
249
365
 
250
366
  const dispatch = useCallback(
251
367
  async (delivery: "send" | "steer", explicit?: string): Promise<boolean> => {
368
+ const ownedTargetKey = targetKey;
369
+ const ownedGeneration = targetGeneration.current;
252
370
  const draftAtSend = value;
253
- const text = (explicit ?? draftAtSend).trim();
371
+ const rawText = explicit ?? draftAtSend;
372
+ const hasText = rawText.trim().length > 0;
254
373
  // Resolve the extras once: a file-only message (empty text + ≥1 ready
255
374
  // resource) is legitimate, so we must not bail on empty text alone.
256
375
  const extras = resolveSendExtras(sendExtrasRef.current);
257
376
  const hasResources = restoredResources.length > 0 || (extras.resources?.length ?? 0) > 0;
258
- if ((!text && !hasResources) || !sessionId || sending) {
377
+ if (
378
+ (!hasText && !hasResources) ||
379
+ !sessionId ||
380
+ sending ||
381
+ targetKeyRef.current !== ownedTargetKey
382
+ ) {
259
383
  return false;
260
384
  }
261
385
  // Reuse the clientEventId across retries of the same draft so a
@@ -264,17 +388,36 @@ export function useComposer(
264
388
  setSending(true);
265
389
  setError(null);
266
390
  try {
267
- const payload = currentDraftPayload();
391
+ // Trimming is only an emptiness check. A non-blank prompt is persisted
392
+ // and submitted byte-for-byte, while file-only sends use the same
393
+ // placeholder for both operations so the server content fence cannot
394
+ // reject its own client.
395
+ const sendText = hasText ? rawText : FILE_ONLY_MESSAGE_TEXT;
396
+ const currentPayload = currentDraftPayload();
397
+ const payload = currentPayload ? { ...currentPayload, text: sendText } : null;
268
398
  if (payload && !(await persistPayload(payload))) return false;
399
+ if (
400
+ targetKeyRef.current !== ownedTargetKey ||
401
+ targetGeneration.current !== ownedGeneration
402
+ ) {
403
+ return false;
404
+ }
269
405
  // The wire contract requires non-empty text (z.string().min(1)) and the
270
406
  // worker rejects whitespace-only text; a file-only message therefore
271
407
  // carries a minimal default so the attachments still get delivered.
272
- const sendText = text || FILE_ONLY_MESSAGE_TEXT;
273
- const input = composeSendInput(sendText, pendingClientEventId.current, extras, {
408
+ const acknowledgedDraft = draftRef.current;
409
+ const sendExtras =
410
+ acknowledgedDraft?.toolsProvided === true &&
411
+ !Object.prototype.hasOwnProperty.call(extras, "tools")
412
+ ? { ...extras, tools: acknowledgedDraft.tools }
413
+ : extras;
414
+ const input = composeSendInput(sendText, pendingClientEventId.current, sendExtras, {
274
415
  ...(options.effectiveControl?.controlEtag
275
416
  ? { controlEtag: options.effectiveControl.controlEtag }
276
417
  : {}),
277
- ...(draftRef.current ? { expectedDraftRevision: draftRef.current.revision } : {}),
418
+ ...(durableDrafts && draftRef.current
419
+ ? { expectedDraftRevision: draftRef.current.revision }
420
+ : {}),
278
421
  resources: mergeResources(restoredResources, extras.resources ?? []),
279
422
  });
280
423
  if (delivery === "steer") {
@@ -282,6 +425,12 @@ export function useComposer(
282
425
  } else {
283
426
  await client.sendMessage(workspaceId, sessionId, input);
284
427
  }
428
+ if (
429
+ targetKeyRef.current !== ownedTargetKey ||
430
+ targetGeneration.current !== ownedGeneration
431
+ ) {
432
+ return false;
433
+ }
285
434
  pendingClientEventId.current = null;
286
435
  const previousDraft = draftRef.current;
287
436
  if (previousDraft) {
@@ -295,6 +444,7 @@ export function useComposer(
295
444
  updatedAt: null,
296
445
  };
297
446
  draftRef.current = cleared;
447
+ restoredResourcesRef.current = [];
298
448
  setDraft(cleared);
299
449
  setRestoredResources([]);
300
450
  lastSavedSignature.current = draftSignature(draftPayload(cleared));
@@ -302,26 +452,41 @@ export function useComposer(
302
452
  if (explicit === undefined) {
303
453
  // Clear only the draft that was sent: edits made while the request
304
454
  // was in flight were never delivered and must survive.
305
- setValue((current) => (current === draftAtSend ? "" : current));
455
+ if (valueRef.current === draftAtSend) {
456
+ valueRef.current = "";
457
+ setValue("");
458
+ }
306
459
  }
307
460
  onSent?.(sendText);
308
461
  return true;
309
462
  } catch (cause) {
310
- setError(cause instanceof Error ? cause : new Error(String(cause)));
463
+ if (
464
+ targetKeyRef.current === ownedTargetKey &&
465
+ targetGeneration.current === ownedGeneration
466
+ ) {
467
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
468
+ }
311
469
  return false;
312
470
  } finally {
313
- setSending(false);
471
+ if (
472
+ targetKeyRef.current === ownedTargetKey &&
473
+ targetGeneration.current === ownedGeneration
474
+ ) {
475
+ setSending(false);
476
+ }
314
477
  }
315
478
  },
316
479
  [
317
480
  client,
318
481
  currentDraftPayload,
482
+ durableDrafts,
319
483
  onSent,
320
484
  options.effectiveControl?.controlEtag,
321
485
  persistPayload,
322
486
  restoredResources,
323
487
  sending,
324
488
  sessionId,
489
+ targetKey,
325
490
  value,
326
491
  workspaceId,
327
492
  ],
@@ -341,7 +506,9 @@ export function useComposer(
341
506
 
342
507
  const pause = useCallback(
343
508
  async (reason?: string): Promise<void> => {
344
- if (!sessionId || pausing) {
509
+ const ownedTargetKey = targetKey;
510
+ const ownedGeneration = targetGeneration.current;
511
+ if (!sessionId || pausing || targetKeyRef.current !== ownedTargetKey) {
345
512
  return;
346
513
  }
347
514
  setPausing(true);
@@ -354,17 +521,29 @@ export function useComposer(
354
521
  : {}),
355
522
  });
356
523
  } catch (cause) {
357
- setError(cause instanceof Error ? cause : new Error(String(cause)));
524
+ if (
525
+ targetKeyRef.current === ownedTargetKey &&
526
+ targetGeneration.current === ownedGeneration
527
+ ) {
528
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
529
+ }
358
530
  } finally {
359
- setPausing(false);
531
+ if (
532
+ targetKeyRef.current === ownedTargetKey &&
533
+ targetGeneration.current === ownedGeneration
534
+ ) {
535
+ setPausing(false);
536
+ }
360
537
  }
361
538
  },
362
- [client, workspaceId, sessionId, pausing, options.effectiveControl?.controlEtag],
539
+ [client, workspaceId, sessionId, pausing, options.effectiveControl?.controlEtag, targetKey],
363
540
  );
364
541
 
365
542
  const resume = useCallback(
366
543
  async (reason?: string): Promise<void> => {
367
- if (!sessionId || resuming) return;
544
+ const ownedTargetKey = targetKey;
545
+ const ownedGeneration = targetGeneration.current;
546
+ if (!sessionId || resuming || targetKeyRef.current !== ownedTargetKey) return;
368
547
  setResuming(true);
369
548
  setError(null);
370
549
  try {
@@ -375,17 +554,29 @@ export function useComposer(
375
554
  : {}),
376
555
  });
377
556
  } catch (cause) {
378
- setError(cause instanceof Error ? cause : new Error(String(cause)));
557
+ if (
558
+ targetKeyRef.current === ownedTargetKey &&
559
+ targetGeneration.current === ownedGeneration
560
+ ) {
561
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
562
+ }
379
563
  } finally {
380
- setResuming(false);
564
+ if (
565
+ targetKeyRef.current === ownedTargetKey &&
566
+ targetGeneration.current === ownedGeneration
567
+ ) {
568
+ setResuming(false);
569
+ }
381
570
  }
382
571
  },
383
- [client, workspaceId, sessionId, resuming, options.effectiveControl?.controlEtag],
572
+ [client, workspaceId, sessionId, resuming, options.effectiveControl?.controlEtag, targetKey],
384
573
  );
385
574
 
386
575
  const resumeScope = useCallback(
387
576
  async (option: EffectiveControlResumeOption): Promise<void> => {
388
- if (!sessionId || resuming) return;
577
+ const ownedTargetKey = targetKey;
578
+ const ownedGeneration = targetGeneration.current;
579
+ if (!sessionId || resuming || targetKeyRef.current !== ownedTargetKey) return;
389
580
  setResuming(true);
390
581
  setError(null);
391
582
  try {
@@ -393,6 +584,11 @@ export function useComposer(
393
584
  const workspaceBlocker = options.effectiveControl?.blockers.find(
394
585
  (blocker) => blocker.kind === "workspace",
395
586
  );
587
+ if (!client.setWorkspaceInferenceState) {
588
+ throw new Error(
589
+ "@opengeni/react: workspace-scoped resume requires setWorkspaceInferenceState.",
590
+ );
591
+ }
396
592
  await client.setWorkspaceInferenceState(workspaceId, {
397
593
  action: "resume",
398
594
  clientEventId: generateClientEventId(),
@@ -400,6 +596,12 @@ export function useComposer(
400
596
  });
401
597
  } else if (option.scope === "session" && option.targetId) {
402
598
  const target = await client.getQueue(workspaceId, option.targetId);
599
+ if (
600
+ targetKeyRef.current !== ownedTargetKey ||
601
+ targetGeneration.current !== ownedGeneration
602
+ ) {
603
+ return;
604
+ }
403
605
  await client.resumeSession(workspaceId, option.targetId, {
404
606
  expectedControlEtag: target.effectiveControl.controlEtag,
405
607
  });
@@ -411,29 +613,69 @@ export function useComposer(
411
613
  });
412
614
  }
413
615
  } catch (cause) {
414
- setError(asError(cause));
616
+ if (
617
+ targetKeyRef.current === ownedTargetKey &&
618
+ targetGeneration.current === ownedGeneration
619
+ ) {
620
+ setError(asError(cause));
621
+ }
415
622
  } finally {
416
- setResuming(false);
623
+ if (
624
+ targetKeyRef.current === ownedTargetKey &&
625
+ targetGeneration.current === ownedGeneration
626
+ ) {
627
+ setResuming(false);
628
+ }
417
629
  }
418
630
  },
419
- [client, options.effectiveControl, resuming, sessionId, workspaceId],
631
+ [client, options.effectiveControl, resuming, sessionId, targetKey, workspaceId],
420
632
  );
421
633
 
422
- const updateValue = useCallback((next: string) => {
423
- pendingClientEventId.current = null;
424
- localEditRevision.current += 1;
425
- setValue(next);
426
- }, []);
634
+ const updateValue = useCallback(
635
+ (next: string) => {
636
+ if (targetKeyRef.current !== targetKey) return;
637
+ pendingClientEventId.current = null;
638
+ localEditRevision.current += 1;
639
+ valueRef.current = next;
640
+ setValue(next);
641
+ },
642
+ [targetKey],
643
+ );
427
644
 
428
- const removeRestoredResource = useCallback((index: number) => {
429
- localEditRevision.current += 1;
430
- setRestoredResources((current) => current.filter((_, candidate) => candidate !== index));
645
+ const removeRestoredResource = useCallback(
646
+ (index: number) => {
647
+ if (targetKeyRef.current !== targetKey) return;
648
+ localEditRevision.current += 1;
649
+ const next = restoredResourcesRef.current.filter((_, candidate) => candidate !== index);
650
+ restoredResourcesRef.current = next;
651
+ setRestoredResources(next);
652
+ },
653
+ [targetKey],
654
+ );
655
+
656
+ const hasDraftContent = useCallback((): boolean => {
657
+ const current = draftRef.current;
658
+ const extras = resolveSendExtras(sendExtrasRef.current);
659
+ const toolsProvidedByHost = Object.prototype.hasOwnProperty.call(extras, "tools");
660
+ const tools = toolsProvidedByHost ? (extras.tools ?? []) : (current?.tools ?? []);
661
+ return (
662
+ valueRef.current.length > 0 ||
663
+ restoredResourcesRef.current.length > 0 ||
664
+ (extras.resources?.length ?? 0) > 0 ||
665
+ tools.length > 0 ||
666
+ (current?.sourceTurnId !== null && current?.sourceTurnId !== undefined)
667
+ );
431
668
  }, []);
432
669
 
433
670
  const resolveDraftConflict = useCallback(
434
671
  async (choice: "keep_mine" | "use_remote"): Promise<void> => {
435
- if (!sessionId) return;
672
+ const ownedTargetKey = targetKey;
673
+ const ownedGeneration = targetGeneration.current;
674
+ if (!sessionId || !durableDrafts || targetKeyRef.current !== ownedTargetKey) return;
436
675
  const remote = await client.getComposerDraft(workspaceId, sessionId);
676
+ if (targetKeyRef.current !== ownedTargetKey || targetGeneration.current !== ownedGeneration) {
677
+ return;
678
+ }
437
679
  if (choice === "use_remote") {
438
680
  applyDraft(remote);
439
681
  return;
@@ -444,36 +686,56 @@ export function useComposer(
444
686
  const payload = currentDraftPayload();
445
687
  if (payload) await persistPayload({ ...payload, expectedRevision: remote.revision });
446
688
  },
447
- [applyDraft, client, currentDraftPayload, persistPayload, sessionId, workspaceId],
689
+ [
690
+ applyDraft,
691
+ client,
692
+ currentDraftPayload,
693
+ durableDrafts,
694
+ persistPayload,
695
+ sessionId,
696
+ targetKey,
697
+ workspaceId,
698
+ ],
448
699
  );
449
700
 
701
+ const identityMatches = stateTargetKey === targetKey;
702
+ const reloadDraft = useCallback(async () => await loadDraft(true), [loadDraft]);
703
+ const clearError = useCallback(() => {
704
+ if (targetKeyRef.current !== targetKey) return;
705
+ setError(null);
706
+ setDraftConflict(null);
707
+ }, [targetKey]);
708
+
450
709
  return {
451
- value,
710
+ value: identityMatches ? value : "",
452
711
  setValue: updateValue,
712
+ hasDraftContent,
453
713
  send,
454
714
  steer,
455
- sending,
456
- canSend: Boolean(sessionId) && !sending && (value.trim().length > 0 || hasReadyResources),
715
+ sending: identityMatches ? sending : false,
716
+ canSend:
717
+ identityMatches &&
718
+ Boolean(sessionId) &&
719
+ !sending &&
720
+ (value.trim().length > 0 || hasReadyResources),
457
721
  pause,
458
- pausing,
722
+ pausing: identityMatches ? pausing : false,
459
723
  resume,
460
724
  resumeScope,
461
- resuming,
462
- draft,
463
- draftRevision: draft?.revision ?? 0,
464
- draftLoading,
465
- draftSaving,
466
- draftConflict,
725
+ resuming: identityMatches ? resuming : false,
726
+ draft: identityMatches ? draft : null,
727
+ draftRevision: identityMatches ? (draft?.revision ?? 0) : 0,
728
+ draftLoading: identityMatches ? draftLoading : Boolean(sessionId) && durableDrafts,
729
+ draftSaving: identityMatches ? draftSaving : false,
730
+ draftConflict: identityMatches ? draftConflict : null,
731
+ draftPersistence: durableDrafts ? "durable" : "disabled",
467
732
  applyDraft,
468
- reloadDraft: useCallback(async () => await loadDraft(true), [loadDraft]),
733
+ reloadDraft,
469
734
  resolveDraftConflict,
470
- restoredResources,
735
+ restoredResources: identityMatches ? restoredResources : [],
471
736
  removeRestoredResource,
472
- error,
473
- clearError: useCallback(() => {
474
- setError(null);
475
- setDraftConflict(null);
476
- }, []),
737
+ error: identityMatches ? error : null,
738
+ clearError,
477
739
  };
478
740
  }
479
741
 
@@ -543,11 +805,30 @@ function draftPayload(draft: ComposerDraft): SaveComposerDraftRequest {
543
805
  text: draft.text,
544
806
  resources: draft.resources,
545
807
  tools: draft.tools,
808
+ toolsProvided: draft.toolsProvided,
546
809
  model: draft.model,
547
810
  reasoningEffort: draft.reasoningEffort,
548
811
  };
549
812
  }
550
813
 
814
+ function composerDraftPayload(
815
+ base: ComposerDraft,
816
+ text: string,
817
+ restoredResources: ResourceRef[],
818
+ extras: ComposerSendExtras,
819
+ ): SaveComposerDraftRequest {
820
+ const toolsProvidedByHost = Object.prototype.hasOwnProperty.call(extras, "tools");
821
+ return {
822
+ expectedRevision: base.revision,
823
+ text,
824
+ resources: mergeResources(restoredResources, extras.resources ?? []),
825
+ tools: toolsProvidedByHost ? (extras.tools ?? []) : base.tools,
826
+ toolsProvided: toolsProvidedByHost ? true : base.toolsProvided,
827
+ model: extras.model ?? base.model,
828
+ reasoningEffort: extras.reasoningEffort ?? base.reasoningEffort,
829
+ };
830
+ }
831
+
551
832
  function draftSignature(payload: SaveComposerDraftRequest): string {
552
833
  const { expectedRevision: _revision, ...content } = payload;
553
834
  return JSON.stringify(content);