@opengeni/react 0.15.0 → 0.20.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 (101) hide show
  1. package/README.md +110 -10
  2. package/dist/{chunk-TOJR776I.js → chunk-6BXBGJ3B.js} +45 -369
  3. package/dist/chunk-6BXBGJ3B.js.map +1 -0
  4. package/dist/chunk-6UT5OC3P.js +2133 -0
  5. package/dist/chunk-6UT5OC3P.js.map +1 -0
  6. package/dist/chunk-EBTT3MYN.js +92 -0
  7. package/dist/chunk-EBTT3MYN.js.map +1 -0
  8. package/dist/chunk-HHFA4AFX.js +425 -0
  9. package/dist/chunk-HHFA4AFX.js.map +1 -0
  10. package/dist/chunk-LAZ2A743.js +2393 -0
  11. package/dist/chunk-LAZ2A743.js.map +1 -0
  12. package/dist/chunk-MRSECXRP.js +563 -0
  13. package/dist/chunk-MRSECXRP.js.map +1 -0
  14. package/dist/chunk-TK7G6XLT.js +18 -0
  15. package/dist/chunk-TK7G6XLT.js.map +1 -0
  16. package/dist/composer-DvUaa5ki.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 +7015 -8806
  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-NQMV2ML3.js +890 -0
  30. package/dist/queue-surface-implementation-NQMV2ML3.js.map +1 -0
  31. package/dist/session-Gd4iVPYw.d.ts +425 -0
  32. package/dist/session-context-DIFFlXKc.d.ts +52 -0
  33. package/dist/session.d.ts +4 -0
  34. package/dist/session.js +50 -0
  35. package/dist/session.js.map +1 -0
  36. package/dist/use-composer-BCfm4mzX.d.ts +112 -0
  37. package/package.json +20 -4
  38. package/src/approvals.ts +5 -3
  39. package/src/client.ts +40 -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 +15 -7
  53. package/src/components/model-picker.tsx +12 -3
  54. package/src/components/pierre-diff.tsx +32 -6
  55. package/src/components/queue-surface-implementation.tsx +1041 -0
  56. package/src/components/queue-surface-state.tsx +94 -0
  57. package/src/components/queue-surface.tsx +41 -559
  58. package/src/components/sandbox-files.tsx +138 -14
  59. package/src/components/sandbox-terminal.tsx +8 -2
  60. package/src/components/sandbox-workspace.tsx +339 -119
  61. package/src/components/workbench-changes.tsx +84 -33
  62. package/src/components/workspace-dock.tsx +188 -18
  63. package/src/composer.ts +60 -0
  64. package/src/hooks/internal.ts +115 -34
  65. package/src/hooks/use-composer.ts +335 -104
  66. package/src/hooks/use-human-input.ts +131 -0
  67. package/src/hooks/use-machine-chip.ts +2 -2
  68. package/src/hooks/use-machines.ts +27 -11
  69. package/src/hooks/use-sandbox-files.ts +310 -62
  70. package/src/hooks/use-sandbox-git.ts +154 -40
  71. package/src/hooks/use-sandbox-terminal.ts +28 -6
  72. package/src/hooks/use-session-capabilities.ts +38 -9
  73. package/src/hooks/use-session-control.ts +37 -8
  74. package/src/hooks/use-session-events.ts +522 -48
  75. package/src/hooks/use-slash-commands.ts +38 -38
  76. package/src/hooks/use-transcription.ts +757 -0
  77. package/src/hooks/use-turn-queue.ts +134 -66
  78. package/src/hooks/use-windowed-sections.ts +2 -2
  79. package/src/hooks/use-workspace-capture.ts +146 -40
  80. package/src/hooks/use-workspace-edit.ts +50 -14
  81. package/src/hooks/use-workspace-sessions.ts +3 -0
  82. package/src/human-input.ts +72 -0
  83. package/src/index.ts +76 -6
  84. package/src/lib/noto-sans-arabic-loader.ts +21 -0
  85. package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
  86. package/src/lib/use-portal-token-style.ts +51 -0
  87. package/src/lib/use-unicode-fonts.ts +57 -0
  88. package/src/provider.tsx +4 -69
  89. package/src/session-context.ts +135 -0
  90. package/src/session.ts +79 -0
  91. package/src/timeline/parsers.ts +35 -0
  92. package/src/timeline/projection.ts +12 -4
  93. package/src/timeline/tool-renderers.tsx +41 -0
  94. package/src/timeline/turn-summary.tsx +2 -2
  95. package/src/timeline/types.ts +16 -8
  96. package/styles/index.css +56 -0
  97. package/styles/index.d.ts +2 -0
  98. package/styles/tokens.css +11 -6
  99. package/styles/tokens.d.ts +2 -0
  100. package/dist/chunk-TOJR776I.js.map +0 -1
  101. 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,6 +27,18 @@ 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
+ /**
31
+ * Opt out of OpenGeni's remote composer-draft reads and writes. Embedded
32
+ * hosts can use this when their human lane permits messages and controls but
33
+ * deliberately withholds draft mutation because model/tool policy is
34
+ * server-owned. The unsent value remains local React state and send/steer
35
+ * continue to work without a draft revision fence. Queue checkout is hidden
36
+ * because the current queue-edit contract necessarily creates a durable
37
+ * composer draft.
38
+ *
39
+ * @default "durable"
40
+ */
41
+ draftPersistence?: "durable" | "disabled" | undefined;
30
42
  };
31
43
 
32
44
  export type ComposerState = {
@@ -49,6 +61,13 @@ export type ComposerState = {
49
61
  draftLoading: boolean;
50
62
  draftSaving: boolean;
51
63
  draftConflict: Error | null;
64
+ /**
65
+ * Whether this controller owns a durable server-side draft. Custom
66
+ * controllers that omit the field retain the historical durable behavior.
67
+ * Queue checkout is unavailable when persistence is disabled because the
68
+ * current queue-edit contract atomically moves the turn into that draft.
69
+ */
70
+ draftPersistence?: "durable" | "disabled" | undefined;
52
71
  /** Apply an atomic queue Edit checkout without a second read. */
53
72
  applyDraft: (draft: ComposerDraft) => void;
54
73
  reloadDraft: () => Promise<void>;
@@ -69,19 +88,24 @@ export function useComposer(
69
88
  sessionId: string | null | undefined,
70
89
  options: UseComposerOptions = {},
71
90
  ): ComposerState {
72
- const { client, workspaceId, registerSessionReconciler } = useOpenGeni(options);
91
+ const { client, workspaceId, registerSessionReconciler } = useEmbeddedSession(options);
92
+ const durableDrafts = options.draftPersistence !== "disabled";
93
+ const targetKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${durableDrafts ? "durable" : "disabled"}`;
73
94
  const [value, setValue] = useState("");
95
+ const [stateTargetKey, setStateTargetKey] = useState(targetKey);
74
96
  const [sending, setSending] = useState(false);
75
97
  const [pausing, setPausing] = useState(false);
76
98
  const [resuming, setResuming] = useState(false);
77
99
  const [error, setError] = useState<Error | null>(null);
78
100
  const [draft, setDraft] = useState<ComposerDraft | null>(null);
79
- const [draftLoading, setDraftLoading] = useState(Boolean(sessionId));
101
+ const [draftLoading, setDraftLoading] = useState(Boolean(sessionId) && durableDrafts);
80
102
  const [draftSaving, setDraftSaving] = useState(false);
81
103
  const [draftConflict, setDraftConflict] = useState<Error | null>(null);
82
104
  const [restoredResources, setRestoredResources] = useState<ResourceRef[]>([]);
83
105
  const pendingClientEventId = useRef<string | null>(null);
84
106
  const draftRef = useRef<ComposerDraft | null>(null);
107
+ const draftLoadErrorRef = useRef<Error | null>(null);
108
+ const draftReadGeneration = useRef(0);
85
109
  const localEditRevision = useRef(0);
86
110
  const targetGeneration = useRef(0);
87
111
  const lastSavedSignature = useRef<string | null>(null);
@@ -91,31 +115,57 @@ export function useComposer(
91
115
  // Read through a ref so a new extras closure (created every render by
92
116
  // callers passing inline functions) does not invalidate `send`.
93
117
  const sendExtrasRef = useRef(options.sendExtras);
94
- sendExtrasRef.current = options.sendExtras;
95
- const liveExtrasVersion = JSON.stringify(resolveSendExtras(options.sendExtras));
118
+ useLayoutEffect(() => {
119
+ sendExtrasRef.current = options.sendExtras;
120
+ }, [options.sendExtras]);
121
+ const liveExtras = resolveSendExtras(options.sendExtras);
122
+ const liveExtrasVersion = JSON.stringify(liveExtras);
96
123
 
97
124
  // A composer is bound to one session: switching targets must not leak the
98
- // previous session's draft, error, or retry idempotency key.
99
- const targetKey = `${workspaceId}\u0000${sessionId ?? ""}`;
125
+ // previous session's draft, error, or retry idempotency key. Revoke the old
126
+ // target only when the new render commits: a suspended transition must leave
127
+ // the still-visible composer fully interactive.
100
128
  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]);
129
+ useLayoutEffect(() => {
130
+ if (targetKeyRef.current === targetKey) return;
131
+ targetKeyRef.current = targetKey;
132
+ targetGeneration.current += 1;
133
+ pendingClientEventId.current = null;
134
+ localEditRevision.current = 0;
135
+ draftReadGeneration.current += 1;
136
+ draftRef.current = null;
137
+ draftLoadErrorRef.current = null;
138
+ lastSavedSignature.current = null;
139
+ // An unresolved save for the old target must neither block nor join the
140
+ // new target's autosave chain. Its own generation fence drops settlement.
141
+ saveChain.current = Promise.resolve();
142
+ setStateTargetKey(targetKey);
143
+ setValue("");
144
+ setSending(false);
145
+ setPausing(false);
146
+ setResuming(false);
147
+ setError(null);
148
+ setDraft(null);
149
+ setDraftLoading(Boolean(sessionId) && durableDrafts);
150
+ setDraftSaving(false);
151
+ setDraftConflict(null);
152
+ setRestoredResources([]);
153
+ }, [durableDrafts, sessionId, targetKey]);
116
154
 
117
155
  const applyDraft = useCallback(
118
156
  (next: ComposerDraft): void => {
157
+ if (targetKeyRef.current !== targetKey) return;
158
+ if (!durableDrafts) {
159
+ pendingClientEventId.current = null;
160
+ localEditRevision.current += 1;
161
+ draftRef.current = null;
162
+ lastSavedSignature.current = null;
163
+ setDraft(null);
164
+ setValue(next.text);
165
+ setRestoredResources(next.resources);
166
+ setDraftConflict(null);
167
+ return;
168
+ }
119
169
  draftRef.current = next;
120
170
  lastSavedSignature.current = draftSignature(draftPayload(next));
121
171
  localEditRevision.current += 1;
@@ -126,47 +176,84 @@ export function useComposer(
126
176
  setDraftConflict(null);
127
177
  onDraftApplied?.(next);
128
178
  },
129
- [onDraftApplied],
179
+ [durableDrafts, onDraftApplied, targetKey],
130
180
  );
131
181
 
132
182
  const loadDraft = useCallback(
133
- async (replaceLocal: boolean): Promise<void> => {
134
- if (!sessionId) return;
183
+ async (replaceLocal: boolean, rejectOnFailure = false): Promise<void> => {
184
+ if (targetKeyRef.current !== targetKey) return;
185
+ if (!sessionId || !durableDrafts) {
186
+ setDraftLoading(false);
187
+ return;
188
+ }
135
189
  const generation = targetGeneration.current;
190
+ const readTicket = ++draftReadGeneration.current;
136
191
  const localAtStart = localEditRevision.current;
137
192
  setDraftLoading(true);
138
193
  try {
139
194
  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);
195
+ if (generation !== targetGeneration.current || targetKeyRef.current !== targetKey) {
196
+ return;
197
+ }
198
+ const ownsLatestRead = readTicket === draftReadGeneration.current;
199
+ const currentRevision = draftRef.current?.revision ?? -1;
200
+ if ((ownsLatestRead || rejectOnFailure) && fetched.revision >= currentRevision) {
201
+ draftRef.current = fetched;
202
+ setDraft(fetched);
203
+ setDraftConflict(null);
204
+ if (replaceLocal || localAtStart === localEditRevision.current) {
205
+ lastSavedSignature.current = draftSignature(draftPayload(fetched));
206
+ setValue(fetched.text);
207
+ setRestoredResources(fetched.resources);
208
+ onDraftApplied?.(fetched);
209
+ }
210
+ }
211
+ const authoritativeRevision = draftRef.current?.revision ?? -1;
212
+ if (rejectOnFailure && authoritativeRevision < fetched.revision) {
213
+ throw new TypeError("Composer draft reconciliation did not commit authoritative state");
214
+ }
215
+ if (ownsLatestRead || rejectOnFailure) {
216
+ const recoveredError = draftLoadErrorRef.current;
217
+ draftLoadErrorRef.current = null;
218
+ if (recoveredError) {
219
+ setError((current) => (current === recoveredError ? null : current));
220
+ }
149
221
  }
150
222
  } catch (cause) {
151
- if (generation === targetGeneration.current) setError(asError(cause));
223
+ if (
224
+ generation === targetGeneration.current &&
225
+ targetKeyRef.current === targetKey &&
226
+ (readTicket === draftReadGeneration.current || rejectOnFailure)
227
+ ) {
228
+ const failure = asError(cause);
229
+ draftLoadErrorRef.current = failure;
230
+ setError(failure);
231
+ }
232
+ if (rejectOnFailure) throw cause;
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,12 +261,14 @@ 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
  },
267
+ async () => await loadDraft(false, true),
180
268
  );
181
269
 
182
270
  const currentDraftPayload = useCallback((): SaveComposerDraftRequest | null => {
271
+ if (targetKeyRef.current !== targetKey) return null;
183
272
  const base = draftRef.current;
184
273
  if (!base) return null;
185
274
  const extras = resolveSendExtras(sendExtrasRef.current);
@@ -191,13 +280,21 @@ export function useComposer(
191
280
  model: extras.model ?? base.model,
192
281
  reasoningEffort: extras.reasoningEffort ?? base.reasoningEffort,
193
282
  };
194
- }, [restoredResources, value]);
283
+ }, [restoredResources, targetKey, value]);
195
284
 
196
285
  const persistPayload = useCallback(
197
286
  async (payload: SaveComposerDraftRequest): Promise<boolean> => {
198
- if (!sessionId) return false;
287
+ const ownedTargetKey = targetKey;
288
+ const ownedGeneration = targetGeneration.current;
289
+ if (!sessionId || targetKeyRef.current !== ownedTargetKey) return false;
199
290
  let success = false;
200
291
  const run = async () => {
292
+ if (
293
+ targetKeyRef.current !== ownedTargetKey ||
294
+ targetGeneration.current !== ownedGeneration
295
+ ) {
296
+ return;
297
+ }
201
298
  const current = draftRef.current;
202
299
  if (!current) return;
203
300
  const request = { ...payload, expectedRevision: current.revision };
@@ -209,36 +306,61 @@ export function useComposer(
209
306
  setDraftSaving(true);
210
307
  try {
211
308
  const saved = await client.saveComposerDraft(workspaceId, sessionId, request);
309
+ if (
310
+ targetKeyRef.current !== ownedTargetKey ||
311
+ targetGeneration.current !== ownedGeneration
312
+ ) {
313
+ return;
314
+ }
212
315
  draftRef.current = saved;
213
316
  setDraft(saved);
214
317
  lastSavedSignature.current = signature;
215
318
  setDraftConflict(null);
216
319
  success = true;
217
320
  } catch (cause) {
218
- const problem = asError(cause);
219
- setDraftConflict(problem);
220
- setError(problem);
321
+ if (
322
+ targetKeyRef.current === ownedTargetKey &&
323
+ targetGeneration.current === ownedGeneration
324
+ ) {
325
+ const problem = asError(cause);
326
+ setDraftConflict(problem);
327
+ setError(problem);
328
+ }
221
329
  } finally {
222
- setDraftSaving(false);
330
+ if (
331
+ targetKeyRef.current === ownedTargetKey &&
332
+ targetGeneration.current === ownedGeneration
333
+ ) {
334
+ setDraftSaving(false);
335
+ }
223
336
  }
224
337
  };
225
338
  saveChain.current = saveChain.current.then(run, run);
226
339
  await saveChain.current;
227
340
  return success;
228
341
  },
229
- [client, sessionId, workspaceId],
342
+ [client, sessionId, targetKey, workspaceId],
230
343
  );
231
344
 
232
345
  // Private durable autosave. A newer local edit is never replaced by an older
233
346
  // response; saves serialize and each reads the latest acknowledged revision.
234
347
  useEffect(() => {
235
- if (!sessionId || draftLoading || sending || !draftRef.current || draftConflict) return;
348
+ if (
349
+ !durableDrafts ||
350
+ !sessionId ||
351
+ draftLoading ||
352
+ sending ||
353
+ !draftRef.current ||
354
+ draftConflict
355
+ )
356
+ return;
236
357
  const payload = currentDraftPayload();
237
358
  if (!payload || draftSignature(payload) === lastSavedSignature.current) return;
238
359
  const timer = window.setTimeout(() => void persistPayload(payload), 500);
239
360
  return () => window.clearTimeout(timer);
240
361
  }, [
241
362
  currentDraftPayload,
363
+ durableDrafts,
242
364
  draftConflict,
243
365
  draftLoading,
244
366
  liveExtrasVersion,
@@ -249,13 +371,21 @@ export function useComposer(
249
371
 
250
372
  const dispatch = useCallback(
251
373
  async (delivery: "send" | "steer", explicit?: string): Promise<boolean> => {
374
+ const ownedTargetKey = targetKey;
375
+ const ownedGeneration = targetGeneration.current;
252
376
  const draftAtSend = value;
253
- const text = (explicit ?? draftAtSend).trim();
377
+ const rawText = explicit ?? draftAtSend;
378
+ const hasText = rawText.trim().length > 0;
254
379
  // Resolve the extras once: a file-only message (empty text + ≥1 ready
255
380
  // resource) is legitimate, so we must not bail on empty text alone.
256
381
  const extras = resolveSendExtras(sendExtrasRef.current);
257
382
  const hasResources = restoredResources.length > 0 || (extras.resources?.length ?? 0) > 0;
258
- if ((!text && !hasResources) || !sessionId || sending) {
383
+ if (
384
+ (!hasText && !hasResources) ||
385
+ !sessionId ||
386
+ sending ||
387
+ targetKeyRef.current !== ownedTargetKey
388
+ ) {
259
389
  return false;
260
390
  }
261
391
  // Reuse the clientEventId across retries of the same draft so a
@@ -264,12 +394,21 @@ export function useComposer(
264
394
  setSending(true);
265
395
  setError(null);
266
396
  try {
267
- const payload = currentDraftPayload();
397
+ // Trimming is only an emptiness check. A non-blank prompt must be sent
398
+ // byte-for-byte as the user wrote it, because expectedDraftRevision
399
+ // binds the submission to that exact durable draft. File-only messages
400
+ // use the same non-blank placeholder in both the saved draft and the
401
+ // submitted prompt so the content fence cannot reject its own client.
402
+ const sendText = hasText ? rawText : FILE_ONLY_MESSAGE_TEXT;
403
+ const currentPayload = currentDraftPayload();
404
+ const payload = currentPayload ? { ...currentPayload, text: sendText } : null;
268
405
  if (payload && !(await persistPayload(payload))) return false;
269
- // The wire contract requires non-empty text (z.string().min(1)) and the
270
- // worker rejects whitespace-only text; a file-only message therefore
271
- // carries a minimal default so the attachments still get delivered.
272
- const sendText = text || FILE_ONLY_MESSAGE_TEXT;
406
+ if (
407
+ targetKeyRef.current !== ownedTargetKey ||
408
+ targetGeneration.current !== ownedGeneration
409
+ ) {
410
+ return false;
411
+ }
273
412
  const input = composeSendInput(sendText, pendingClientEventId.current, extras, {
274
413
  ...(options.effectiveControl?.controlEtag
275
414
  ? { controlEtag: options.effectiveControl.controlEtag }
@@ -282,6 +421,12 @@ export function useComposer(
282
421
  } else {
283
422
  await client.sendMessage(workspaceId, sessionId, input);
284
423
  }
424
+ if (
425
+ targetKeyRef.current !== ownedTargetKey ||
426
+ targetGeneration.current !== ownedGeneration
427
+ ) {
428
+ return false;
429
+ }
285
430
  pendingClientEventId.current = null;
286
431
  const previousDraft = draftRef.current;
287
432
  if (previousDraft) {
@@ -307,10 +452,20 @@ export function useComposer(
307
452
  onSent?.(sendText);
308
453
  return true;
309
454
  } catch (cause) {
310
- setError(cause instanceof Error ? cause : new Error(String(cause)));
455
+ if (
456
+ targetKeyRef.current === ownedTargetKey &&
457
+ targetGeneration.current === ownedGeneration
458
+ ) {
459
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
460
+ }
311
461
  return false;
312
462
  } finally {
313
- setSending(false);
463
+ if (
464
+ targetKeyRef.current === ownedTargetKey &&
465
+ targetGeneration.current === ownedGeneration
466
+ ) {
467
+ setSending(false);
468
+ }
314
469
  }
315
470
  },
316
471
  [
@@ -322,6 +477,7 @@ export function useComposer(
322
477
  restoredResources,
323
478
  sending,
324
479
  sessionId,
480
+ targetKey,
325
481
  value,
326
482
  workspaceId,
327
483
  ],
@@ -335,13 +491,13 @@ export function useComposer(
335
491
  // — keeping useComposer attachment-agnostic while still lighting up the send
336
492
  // affordance the moment a file is ready. ChatComposer additionally gates this
337
493
  // on its `attachments.uploading` flag so a message never departs mid-upload.
338
- const hasReadyResources =
339
- restoredResources.length > 0 ||
340
- (resolveSendExtras(sendExtrasRef.current).resources?.length ?? 0) > 0;
494
+ const hasReadyResources = restoredResources.length > 0 || (liveExtras.resources?.length ?? 0) > 0;
341
495
 
342
496
  const pause = useCallback(
343
497
  async (reason?: string): Promise<void> => {
344
- if (!sessionId || pausing) {
498
+ const ownedTargetKey = targetKey;
499
+ const ownedGeneration = targetGeneration.current;
500
+ if (!sessionId || pausing || targetKeyRef.current !== ownedTargetKey) {
345
501
  return;
346
502
  }
347
503
  setPausing(true);
@@ -354,17 +510,29 @@ export function useComposer(
354
510
  : {}),
355
511
  });
356
512
  } catch (cause) {
357
- setError(cause instanceof Error ? cause : new Error(String(cause)));
513
+ if (
514
+ targetKeyRef.current === ownedTargetKey &&
515
+ targetGeneration.current === ownedGeneration
516
+ ) {
517
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
518
+ }
358
519
  } finally {
359
- setPausing(false);
520
+ if (
521
+ targetKeyRef.current === ownedTargetKey &&
522
+ targetGeneration.current === ownedGeneration
523
+ ) {
524
+ setPausing(false);
525
+ }
360
526
  }
361
527
  },
362
- [client, workspaceId, sessionId, pausing, options.effectiveControl?.controlEtag],
528
+ [client, workspaceId, sessionId, pausing, options.effectiveControl?.controlEtag, targetKey],
363
529
  );
364
530
 
365
531
  const resume = useCallback(
366
532
  async (reason?: string): Promise<void> => {
367
- if (!sessionId || resuming) return;
533
+ const ownedTargetKey = targetKey;
534
+ const ownedGeneration = targetGeneration.current;
535
+ if (!sessionId || resuming || targetKeyRef.current !== ownedTargetKey) return;
368
536
  setResuming(true);
369
537
  setError(null);
370
538
  try {
@@ -375,21 +543,36 @@ export function useComposer(
375
543
  : {}),
376
544
  });
377
545
  } catch (cause) {
378
- setError(cause instanceof Error ? cause : new Error(String(cause)));
546
+ if (
547
+ targetKeyRef.current === ownedTargetKey &&
548
+ targetGeneration.current === ownedGeneration
549
+ ) {
550
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
551
+ }
379
552
  } finally {
380
- setResuming(false);
553
+ if (
554
+ targetKeyRef.current === ownedTargetKey &&
555
+ targetGeneration.current === ownedGeneration
556
+ ) {
557
+ setResuming(false);
558
+ }
381
559
  }
382
560
  },
383
- [client, workspaceId, sessionId, resuming, options.effectiveControl?.controlEtag],
561
+ [client, workspaceId, sessionId, resuming, options.effectiveControl?.controlEtag, targetKey],
384
562
  );
385
563
 
386
564
  const resumeScope = useCallback(
387
565
  async (option: EffectiveControlResumeOption): Promise<void> => {
388
- if (!sessionId || resuming) return;
566
+ const ownedTargetKey = targetKey;
567
+ const ownedGeneration = targetGeneration.current;
568
+ if (!sessionId || resuming || targetKeyRef.current !== ownedTargetKey) return;
389
569
  setResuming(true);
390
570
  setError(null);
391
571
  try {
392
572
  if (option.scope === "workspace") {
573
+ if (!client.setWorkspaceInferenceState) {
574
+ throw new Error("Workspace-level resume is not available through this client");
575
+ }
393
576
  const workspaceBlocker = options.effectiveControl?.blockers.find(
394
577
  (blocker) => blocker.kind === "workspace",
395
578
  );
@@ -400,6 +583,12 @@ export function useComposer(
400
583
  });
401
584
  } else if (option.scope === "session" && option.targetId) {
402
585
  const target = await client.getQueue(workspaceId, option.targetId);
586
+ if (
587
+ targetKeyRef.current !== ownedTargetKey ||
588
+ targetGeneration.current !== ownedGeneration
589
+ ) {
590
+ return;
591
+ }
403
592
  await client.resumeSession(workspaceId, option.targetId, {
404
593
  expectedControlEtag: target.effectiveControl.controlEtag,
405
594
  });
@@ -411,29 +600,52 @@ export function useComposer(
411
600
  });
412
601
  }
413
602
  } catch (cause) {
414
- setError(asError(cause));
603
+ if (
604
+ targetKeyRef.current === ownedTargetKey &&
605
+ targetGeneration.current === ownedGeneration
606
+ ) {
607
+ setError(asError(cause));
608
+ }
415
609
  } finally {
416
- setResuming(false);
610
+ if (
611
+ targetKeyRef.current === ownedTargetKey &&
612
+ targetGeneration.current === ownedGeneration
613
+ ) {
614
+ setResuming(false);
615
+ }
417
616
  }
418
617
  },
419
- [client, options.effectiveControl, resuming, sessionId, workspaceId],
618
+ [client, options.effectiveControl, resuming, sessionId, targetKey, workspaceId],
420
619
  );
421
620
 
422
- const updateValue = useCallback((next: string) => {
423
- pendingClientEventId.current = null;
424
- localEditRevision.current += 1;
425
- setValue(next);
426
- }, []);
621
+ const updateValue = useCallback(
622
+ (next: string) => {
623
+ if (targetKeyRef.current !== targetKey) return;
624
+ pendingClientEventId.current = null;
625
+ localEditRevision.current += 1;
626
+ setValue(next);
627
+ },
628
+ [targetKey],
629
+ );
427
630
 
428
- const removeRestoredResource = useCallback((index: number) => {
429
- localEditRevision.current += 1;
430
- setRestoredResources((current) => current.filter((_, candidate) => candidate !== index));
431
- }, []);
631
+ const removeRestoredResource = useCallback(
632
+ (index: number) => {
633
+ if (targetKeyRef.current !== targetKey) return;
634
+ localEditRevision.current += 1;
635
+ setRestoredResources((current) => current.filter((_, candidate) => candidate !== index));
636
+ },
637
+ [targetKey],
638
+ );
432
639
 
433
640
  const resolveDraftConflict = useCallback(
434
641
  async (choice: "keep_mine" | "use_remote"): Promise<void> => {
435
- if (!sessionId) return;
642
+ const ownedTargetKey = targetKey;
643
+ const ownedGeneration = targetGeneration.current;
644
+ if (!sessionId || !durableDrafts || targetKeyRef.current !== ownedTargetKey) return;
436
645
  const remote = await client.getComposerDraft(workspaceId, sessionId);
646
+ if (targetKeyRef.current !== ownedTargetKey || targetGeneration.current !== ownedGeneration) {
647
+ return;
648
+ }
437
649
  if (choice === "use_remote") {
438
650
  applyDraft(remote);
439
651
  return;
@@ -444,36 +656,55 @@ export function useComposer(
444
656
  const payload = currentDraftPayload();
445
657
  if (payload) await persistPayload({ ...payload, expectedRevision: remote.revision });
446
658
  },
447
- [applyDraft, client, currentDraftPayload, persistPayload, sessionId, workspaceId],
659
+ [
660
+ applyDraft,
661
+ client,
662
+ currentDraftPayload,
663
+ durableDrafts,
664
+ persistPayload,
665
+ sessionId,
666
+ targetKey,
667
+ workspaceId,
668
+ ],
448
669
  );
449
670
 
671
+ const identityMatches = stateTargetKey === targetKey;
672
+ const reloadDraft = useCallback(async () => await loadDraft(true), [loadDraft]);
673
+ const clearError = useCallback(() => {
674
+ if (targetKeyRef.current !== targetKey) return;
675
+ setError(null);
676
+ setDraftConflict(null);
677
+ }, [targetKey]);
678
+
450
679
  return {
451
- value,
680
+ value: identityMatches ? value : "",
452
681
  setValue: updateValue,
453
682
  send,
454
683
  steer,
455
- sending,
456
- canSend: Boolean(sessionId) && !sending && (value.trim().length > 0 || hasReadyResources),
684
+ sending: identityMatches ? sending : false,
685
+ canSend:
686
+ identityMatches &&
687
+ Boolean(sessionId) &&
688
+ !sending &&
689
+ (value.trim().length > 0 || hasReadyResources),
457
690
  pause,
458
- pausing,
691
+ pausing: identityMatches ? pausing : false,
459
692
  resume,
460
693
  resumeScope,
461
- resuming,
462
- draft,
463
- draftRevision: draft?.revision ?? 0,
464
- draftLoading,
465
- draftSaving,
466
- draftConflict,
694
+ resuming: identityMatches ? resuming : false,
695
+ draft: identityMatches ? draft : null,
696
+ draftRevision: identityMatches ? (draft?.revision ?? 0) : 0,
697
+ draftLoading: identityMatches ? draftLoading : Boolean(sessionId) && durableDrafts,
698
+ draftSaving: identityMatches ? draftSaving : false,
699
+ draftConflict: identityMatches ? draftConflict : null,
700
+ draftPersistence: durableDrafts ? "durable" : "disabled",
467
701
  applyDraft,
468
- reloadDraft: useCallback(async () => await loadDraft(true), [loadDraft]),
702
+ reloadDraft,
469
703
  resolveDraftConflict,
470
- restoredResources,
704
+ restoredResources: identityMatches ? restoredResources : [],
471
705
  removeRestoredResource,
472
- error,
473
- clearError: useCallback(() => {
474
- setError(null);
475
- setDraftConflict(null);
476
- }, []),
706
+ error: identityMatches ? error : null,
707
+ clearError,
477
708
  };
478
709
  }
479
710