@opengeni/react 0.20.0 → 0.25.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 (60) hide show
  1. package/dist/{chunk-6BXBGJ3B.js → chunk-6XUS5VFM.js} +2 -2
  2. package/dist/{chunk-MRSECXRP.js → chunk-AVPU5PMC.js} +87 -57
  3. package/dist/chunk-AVPU5PMC.js.map +1 -0
  4. package/dist/{chunk-HHFA4AFX.js → chunk-I3BJZIG5.js} +15 -1
  5. package/dist/chunk-I3BJZIG5.js.map +1 -0
  6. package/dist/{chunk-LAZ2A743.js → chunk-RDPDU4TA.js} +8 -7
  7. package/dist/chunk-RDPDU4TA.js.map +1 -0
  8. package/dist/{chunk-6UT5OC3P.js → chunk-SHOFILHJ.js} +130 -16
  9. package/dist/chunk-SHOFILHJ.js.map +1 -0
  10. package/dist/{chunk-EBTT3MYN.js → chunk-YDNWMKXO.js} +11 -1
  11. package/dist/chunk-YDNWMKXO.js.map +1 -0
  12. package/dist/{composer-DvUaa5ki.d.ts → composer-BXb0Q1HF.d.ts} +19 -5
  13. package/dist/composer.d.ts +3 -3
  14. package/dist/composer.js +3 -3
  15. package/dist/fleet-decision-projection-SLNZMS42.js +241 -0
  16. package/dist/fleet-decision-projection-SLNZMS42.js.map +1 -0
  17. package/dist/fleet-decision-row-GGCAT4E4.js +351 -0
  18. package/dist/fleet-decision-row-GGCAT4E4.js.map +1 -0
  19. package/dist/index.d.ts +14 -11
  20. package/dist/index.js +466 -244
  21. package/dist/index.js.map +1 -1
  22. package/dist/machines.d.ts +1 -1
  23. package/dist/machines.js +2 -2
  24. package/dist/{queue-surface-implementation-NQMV2ML3.js → queue-surface-implementation-3UW3MIIR.js} +9 -9
  25. package/dist/queue-surface-implementation-3UW3MIIR.js.map +1 -0
  26. package/dist/{session-Gd4iVPYw.d.ts → session-BEvtFWhe.d.ts} +71 -24
  27. package/dist/{session-context-DIFFlXKc.d.ts → session-context-rgrfElMl.d.ts} +7 -1
  28. package/dist/session.d.ts +3 -3
  29. package/dist/session.js +7 -3
  30. package/dist/{use-composer-BCfm4mzX.d.ts → use-composer-BnEzheSk.d.ts} +13 -20
  31. package/package.json +2 -2
  32. package/src/client.ts +4 -0
  33. package/src/components/composer.tsx +7 -6
  34. package/src/components/message-timeline.tsx +197 -25
  35. package/src/components/queue-draft-policy.ts +18 -0
  36. package/src/components/queue-surface-implementation.tsx +6 -10
  37. package/src/components/queue-surface.tsx +1 -0
  38. package/src/hooks/use-composer.ts +143 -83
  39. package/src/hooks/use-file-attachments.ts +95 -2
  40. package/src/hooks/use-goal.ts +22 -4
  41. package/src/hooks/use-session-mcp-approval-policy.ts +161 -0
  42. package/src/hooks/use-workspace-sessions.ts +30 -5
  43. package/src/index.ts +9 -0
  44. package/src/session-context.ts +25 -0
  45. package/src/session.ts +10 -0
  46. package/src/timeline/activity-rail.tsx +17 -1
  47. package/src/timeline/fleet-decision-projection.ts +366 -0
  48. package/src/timeline/fleet-decision-row.tsx +412 -0
  49. package/src/timeline/index.ts +2 -0
  50. package/src/timeline/projection.ts +19 -8
  51. package/src/timeline/tool-display-name.ts +16 -0
  52. package/src/timeline/tool-renderers.tsx +1 -1
  53. package/src/timeline/types.ts +76 -16
  54. package/dist/chunk-6UT5OC3P.js.map +0 -1
  55. package/dist/chunk-EBTT3MYN.js.map +0 -1
  56. package/dist/chunk-HHFA4AFX.js.map +0 -1
  57. package/dist/chunk-LAZ2A743.js.map +0 -1
  58. package/dist/chunk-MRSECXRP.js.map +0 -1
  59. package/dist/queue-surface-implementation-NQMV2ML3.js.map +0 -1
  60. /package/dist/{chunk-6BXBGJ3B.js.map → chunk-6XUS5VFM.js.map} +0 -0
@@ -15,35 +15,33 @@ export type ComposerSendExtras = Omit<SendMessageInput, "text" | "clientEventId"
15
15
 
16
16
  export type UseComposerOptions = EmbeddedSessionClientOverride &
17
17
  SessionEventFeedOptions & {
18
- /** Called with the accepted text after a successful send. */
19
- onSent?: ((text: string) => void) | undefined;
18
+ /** Called with the exact accepted wire input after a successful send. */
19
+ onSent?: ((text: string, input: SendMessageInput) => void) | undefined;
20
20
  /**
21
21
  * Extra message fields (resources, tools, model, reasoningEffort) merged
22
22
  * into every send. A function is evaluated at send time so it can read the
23
23
  * surrounding UI state (attachment pickers, model selectors, ...).
24
24
  */
25
25
  sendExtras?: ComposerSendExtras | (() => ComposerSendExtras) | undefined;
26
+ /**
27
+ * Fail-closed delivery guard evaluated at send time. Attachment hosts use
28
+ * this to preserve unresolved upload cards until the operator waits,
29
+ * retries, or removes them; direct hook callers cannot bypass the UI gate.
30
+ */
31
+ sendBlocked?: (() => boolean) | undefined;
26
32
  /** Latest server-derived workstream control; bound into Send/Steer OCC. */
27
33
  effectiveControl?: EffectiveSessionControl | null | undefined;
28
34
  /** Apply durable model/tool/reasoning settings in the host's controlled UI. */
29
35
  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
- */
36
+ /** Disable remote composer-draft reads and writes for embedded hosts. */
41
37
  draftPersistence?: "durable" | "disabled" | undefined;
42
38
  };
43
39
 
44
40
  export type ComposerState = {
45
41
  value: string;
46
42
  setValue: (value: string) => void;
43
+ /** Read the current draft synchronously before a destructive replacement. */
44
+ hasDraftContent: () => boolean;
47
45
  /** Append the draft behind prompts already visible in the queue. */
48
46
  send: (text?: string) => Promise<boolean>;
49
47
  /** Supersede current direction with the draft. */
@@ -61,12 +59,7 @@ export type ComposerState = {
61
59
  draftLoading: boolean;
62
60
  draftSaving: boolean;
63
61
  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
- */
62
+ /** Whether this controller owns a durable server-side draft. */
70
63
  draftPersistence?: "durable" | "disabled" | undefined;
71
64
  /** Apply an atomic queue Edit checkout without a second read. */
72
65
  applyDraft: (draft: ComposerDraft) => void;
@@ -92,6 +85,8 @@ export function useComposer(
92
85
  const durableDrafts = options.draftPersistence !== "disabled";
93
86
  const targetKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${durableDrafts ? "durable" : "disabled"}`;
94
87
  const [value, setValue] = useState("");
88
+ // Keep rendered state behind the committed target identity for one frame:
89
+ // a parent may switch sessionId without remounting this public hook.
95
90
  const [stateTargetKey, setStateTargetKey] = useState(targetKey);
96
91
  const [sending, setSending] = useState(false);
97
92
  const [pausing, setPausing] = useState(false);
@@ -103,11 +98,12 @@ export function useComposer(
103
98
  const [draftConflict, setDraftConflict] = useState<Error | null>(null);
104
99
  const [restoredResources, setRestoredResources] = useState<ResourceRef[]>([]);
105
100
  const pendingClientEventId = useRef<string | null>(null);
101
+ const valueRef = useRef("");
106
102
  const draftRef = useRef<ComposerDraft | null>(null);
107
- const draftLoadErrorRef = useRef<Error | null>(null);
108
- const draftReadGeneration = useRef(0);
103
+ const restoredResourcesRef = useRef<ResourceRef[]>([]);
109
104
  const localEditRevision = useRef(0);
110
105
  const targetGeneration = useRef(0);
106
+ const draftReadGeneration = useRef(0);
111
107
  const lastSavedSignature = useRef<string | null>(null);
112
108
  const saveChain = useRef<Promise<void>>(Promise.resolve());
113
109
  const onSent = options.onSent;
@@ -115,29 +111,27 @@ export function useComposer(
115
111
  // Read through a ref so a new extras closure (created every render by
116
112
  // callers passing inline functions) does not invalidate `send`.
117
113
  const sendExtrasRef = useRef(options.sendExtras);
118
- useLayoutEffect(() => {
119
- sendExtrasRef.current = options.sendExtras;
120
- }, [options.sendExtras]);
121
- const liveExtras = resolveSendExtras(options.sendExtras);
122
- const liveExtrasVersion = JSON.stringify(liveExtras);
114
+ sendExtrasRef.current = options.sendExtras;
115
+ const sendBlockedRef = useRef(options.sendBlocked);
116
+ sendBlockedRef.current = options.sendBlocked;
117
+ const liveExtrasVersion = JSON.stringify(resolveSendExtras(options.sendExtras));
123
118
 
124
119
  // A composer is bound to one session: switching targets must not leak the
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.
120
+ // previous session's draft, error, or retry idempotency key.
128
121
  const targetKeyRef = useRef(targetKey);
129
122
  useLayoutEffect(() => {
130
123
  if (targetKeyRef.current === targetKey) return;
131
124
  targetKeyRef.current = targetKey;
132
125
  targetGeneration.current += 1;
126
+ draftReadGeneration.current += 1;
133
127
  pendingClientEventId.current = null;
134
128
  localEditRevision.current = 0;
135
- draftReadGeneration.current += 1;
129
+ valueRef.current = "";
136
130
  draftRef.current = null;
137
- draftLoadErrorRef.current = null;
131
+ restoredResourcesRef.current = [];
138
132
  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.
133
+ // Old saves may still be awaiting the network. Their generation fence
134
+ // prevents settlement, and a fresh chain avoids blocking this target.
141
135
  saveChain.current = Promise.resolve();
142
136
  setStateTargetKey(targetKey);
143
137
  setValue("");
@@ -158,6 +152,8 @@ export function useComposer(
158
152
  if (!durableDrafts) {
159
153
  pendingClientEventId.current = null;
160
154
  localEditRevision.current += 1;
155
+ valueRef.current = next.text;
156
+ restoredResourcesRef.current = next.resources;
161
157
  draftRef.current = null;
162
158
  lastSavedSignature.current = null;
163
159
  setDraft(null);
@@ -166,7 +162,9 @@ export function useComposer(
166
162
  setDraftConflict(null);
167
163
  return;
168
164
  }
165
+ valueRef.current = next.text;
169
166
  draftRef.current = next;
167
+ restoredResourcesRef.current = next.resources;
170
168
  lastSavedSignature.current = draftSignature(draftPayload(next));
171
169
  localEditRevision.current += 1;
172
170
  pendingClientEventId.current = null;
@@ -180,7 +178,7 @@ export function useComposer(
180
178
  );
181
179
 
182
180
  const loadDraft = useCallback(
183
- async (replaceLocal: boolean, rejectOnFailure = false): Promise<void> => {
181
+ async (replaceLocal: boolean): Promise<void> => {
184
182
  if (targetKeyRef.current !== targetKey) return;
185
183
  if (!sessionId || !durableDrafts) {
186
184
  setDraftLoading(false);
@@ -189,47 +187,57 @@ export function useComposer(
189
187
  const generation = targetGeneration.current;
190
188
  const readTicket = ++draftReadGeneration.current;
191
189
  const localAtStart = localEditRevision.current;
190
+ const baseAtStart = draftRef.current;
191
+ const extrasAtStart = resolveSendExtras(sendExtrasRef.current);
192
+ const localSignatureAtStart = baseAtStart
193
+ ? draftSignature(
194
+ composerDraftPayload(
195
+ baseAtStart,
196
+ valueRef.current,
197
+ restoredResourcesRef.current,
198
+ extrasAtStart,
199
+ ),
200
+ )
201
+ : null;
202
+ const localWasDirtyAtStart =
203
+ localSignatureAtStart === null
204
+ ? localAtStart !== 0
205
+ : localSignatureAtStart !== lastSavedSignature.current;
192
206
  setDraftLoading(true);
193
207
  try {
194
208
  const fetched = await client.getComposerDraft(workspaceId, sessionId);
195
- if (generation !== targetGeneration.current || targetKeyRef.current !== targetKey) {
209
+ if (
210
+ generation !== targetGeneration.current ||
211
+ targetKeyRef.current !== targetKey ||
212
+ readTicket !== draftReadGeneration.current
213
+ ) {
196
214
  return;
197
215
  }
198
- const ownsLatestRead = readTicket === draftReadGeneration.current;
199
216
  const currentRevision = draftRef.current?.revision ?? -1;
200
- if ((ownsLatestRead || rejectOnFailure) && fetched.revision >= currentRevision) {
217
+ if (fetched.revision >= currentRevision) {
201
218
  draftRef.current = fetched;
202
219
  setDraft(fetched);
203
220
  setDraftConflict(null);
204
- if (replaceLocal || localAtStart === localEditRevision.current) {
221
+ if (
222
+ replaceLocal ||
223
+ (!localWasDirtyAtStart && localAtStart === localEditRevision.current)
224
+ ) {
225
+ valueRef.current = fetched.text;
226
+ restoredResourcesRef.current = fetched.resources;
205
227
  lastSavedSignature.current = draftSignature(draftPayload(fetched));
206
228
  setValue(fetched.text);
207
229
  setRestoredResources(fetched.resources);
208
230
  onDraftApplied?.(fetched);
209
231
  }
210
232
  }
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
- }
221
- }
222
233
  } catch (cause) {
223
234
  if (
224
235
  generation === targetGeneration.current &&
225
236
  targetKeyRef.current === targetKey &&
226
- (readTicket === draftReadGeneration.current || rejectOnFailure)
237
+ readTicket === draftReadGeneration.current
227
238
  ) {
228
- const failure = asError(cause);
229
- draftLoadErrorRef.current = failure;
230
- setError(failure);
239
+ setError(asError(cause));
231
240
  }
232
- if (rejectOnFailure) throw cause;
233
241
  } finally {
234
242
  if (
235
243
  generation === targetGeneration.current &&
@@ -264,29 +272,23 @@ export function useComposer(
264
272
  enabled: Boolean(sessionId) && durableDrafts,
265
273
  ...(options.events !== undefined ? { events: options.events } : {}),
266
274
  },
267
- async () => await loadDraft(false, true),
268
275
  );
269
276
 
270
277
  const currentDraftPayload = useCallback((): SaveComposerDraftRequest | null => {
271
- if (targetKeyRef.current !== targetKey) return null;
278
+ if (!durableDrafts || targetKeyRef.current !== targetKey) return null;
272
279
  const base = draftRef.current;
273
280
  if (!base) return null;
274
281
  const extras = resolveSendExtras(sendExtrasRef.current);
275
- return {
276
- expectedRevision: base.revision,
277
- text: value,
278
- resources: mergeResources(restoredResources, extras.resources ?? []),
279
- tools: extras.tools ?? base.tools,
280
- model: extras.model ?? base.model,
281
- reasoningEffort: extras.reasoningEffort ?? base.reasoningEffort,
282
- };
283
- }, [restoredResources, targetKey, value]);
282
+ return composerDraftPayload(base, value, restoredResources, extras);
283
+ }, [durableDrafts, restoredResources, targetKey, value]);
284
284
 
285
285
  const persistPayload = useCallback(
286
286
  async (payload: SaveComposerDraftRequest): Promise<boolean> => {
287
287
  const ownedTargetKey = targetKey;
288
288
  const ownedGeneration = targetGeneration.current;
289
- if (!sessionId || targetKeyRef.current !== ownedTargetKey) return false;
289
+ if (!sessionId || !durableDrafts || targetKeyRef.current !== ownedTargetKey) {
290
+ return false;
291
+ }
290
292
  let success = false;
291
293
  const run = async () => {
292
294
  if (
@@ -339,7 +341,7 @@ export function useComposer(
339
341
  await saveChain.current;
340
342
  return success;
341
343
  },
342
- [client, sessionId, targetKey, workspaceId],
344
+ [client, durableDrafts, sessionId, targetKey, workspaceId],
343
345
  );
344
346
 
345
347
  // Private durable autosave. A newer local edit is never replaced by an older
@@ -384,6 +386,7 @@ export function useComposer(
384
386
  (!hasText && !hasResources) ||
385
387
  !sessionId ||
386
388
  sending ||
389
+ sendBlockedRef.current?.() === true ||
387
390
  targetKeyRef.current !== ownedTargetKey
388
391
  ) {
389
392
  return false;
@@ -394,11 +397,10 @@ export function useComposer(
394
397
  setSending(true);
395
398
  setError(null);
396
399
  try {
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.
400
+ // Trimming is only an emptiness check. A non-blank prompt is persisted
401
+ // and submitted byte-for-byte, while file-only sends use the same
402
+ // placeholder for both operations so the server content fence cannot
403
+ // reject its own client.
402
404
  const sendText = hasText ? rawText : FILE_ONLY_MESSAGE_TEXT;
403
405
  const currentPayload = currentDraftPayload();
404
406
  const payload = currentPayload ? { ...currentPayload, text: sendText } : null;
@@ -409,11 +411,22 @@ export function useComposer(
409
411
  ) {
410
412
  return false;
411
413
  }
412
- const input = composeSendInput(sendText, pendingClientEventId.current, extras, {
414
+ // The wire contract requires non-empty text (z.string().min(1)) and the
415
+ // worker rejects whitespace-only text; a file-only message therefore
416
+ // carries a minimal default so the attachments still get delivered.
417
+ const acknowledgedDraft = draftRef.current;
418
+ const sendExtras =
419
+ acknowledgedDraft?.toolsProvided === true &&
420
+ !Object.prototype.hasOwnProperty.call(extras, "tools")
421
+ ? { ...extras, tools: acknowledgedDraft.tools }
422
+ : extras;
423
+ const input = composeSendInput(sendText, pendingClientEventId.current, sendExtras, {
413
424
  ...(options.effectiveControl?.controlEtag
414
425
  ? { controlEtag: options.effectiveControl.controlEtag }
415
426
  : {}),
416
- ...(draftRef.current ? { expectedDraftRevision: draftRef.current.revision } : {}),
427
+ ...(durableDrafts && draftRef.current
428
+ ? { expectedDraftRevision: draftRef.current.revision }
429
+ : {}),
417
430
  resources: mergeResources(restoredResources, extras.resources ?? []),
418
431
  });
419
432
  if (delivery === "steer") {
@@ -440,6 +453,7 @@ export function useComposer(
440
453
  updatedAt: null,
441
454
  };
442
455
  draftRef.current = cleared;
456
+ restoredResourcesRef.current = [];
443
457
  setDraft(cleared);
444
458
  setRestoredResources([]);
445
459
  lastSavedSignature.current = draftSignature(draftPayload(cleared));
@@ -447,9 +461,12 @@ export function useComposer(
447
461
  if (explicit === undefined) {
448
462
  // Clear only the draft that was sent: edits made while the request
449
463
  // was in flight were never delivered and must survive.
450
- setValue((current) => (current === draftAtSend ? "" : current));
464
+ if (valueRef.current === draftAtSend) {
465
+ valueRef.current = "";
466
+ setValue("");
467
+ }
451
468
  }
452
- onSent?.(sendText);
469
+ onSent?.(sendText, input);
453
470
  return true;
454
471
  } catch (cause) {
455
472
  if (
@@ -471,6 +488,7 @@ export function useComposer(
471
488
  [
472
489
  client,
473
490
  currentDraftPayload,
491
+ durableDrafts,
474
492
  onSent,
475
493
  options.effectiveControl?.controlEtag,
476
494
  persistPayload,
@@ -489,9 +507,11 @@ export function useComposer(
489
507
  // A send is possible with non-empty text OR with ≥1 attached resource (a
490
508
  // file-only message). Resources ride in `sendExtras`, so we resolve them here
491
509
  // — keeping useComposer attachment-agnostic while still lighting up the send
492
- // affordance the moment a file is ready. ChatComposer additionally gates this
493
- // on its `attachments.uploading` flag so a message never departs mid-upload.
494
- const hasReadyResources = restoredResources.length > 0 || (liveExtras.resources?.length ?? 0) > 0;
510
+ // affordance the moment a file is ready. Attachment hosts bind `sendBlocked`
511
+ // to unresolved uploads so direct send()/steer() calls fail closed too.
512
+ const hasReadyResources =
513
+ restoredResources.length > 0 ||
514
+ (resolveSendExtras(sendExtrasRef.current).resources?.length ?? 0) > 0;
495
515
 
496
516
  const pause = useCallback(
497
517
  async (reason?: string): Promise<void> => {
@@ -570,12 +590,14 @@ export function useComposer(
570
590
  setError(null);
571
591
  try {
572
592
  if (option.scope === "workspace") {
573
- if (!client.setWorkspaceInferenceState) {
574
- throw new Error("Workspace-level resume is not available through this client");
575
- }
576
593
  const workspaceBlocker = options.effectiveControl?.blockers.find(
577
594
  (blocker) => blocker.kind === "workspace",
578
595
  );
596
+ if (!client.setWorkspaceInferenceState) {
597
+ throw new Error(
598
+ "@opengeni/react: workspace-scoped resume requires setWorkspaceInferenceState.",
599
+ );
600
+ }
579
601
  await client.setWorkspaceInferenceState(workspaceId, {
580
602
  action: "resume",
581
603
  clientEventId: generateClientEventId(),
@@ -623,6 +645,7 @@ export function useComposer(
623
645
  if (targetKeyRef.current !== targetKey) return;
624
646
  pendingClientEventId.current = null;
625
647
  localEditRevision.current += 1;
648
+ valueRef.current = next;
626
649
  setValue(next);
627
650
  },
628
651
  [targetKey],
@@ -632,11 +655,27 @@ export function useComposer(
632
655
  (index: number) => {
633
656
  if (targetKeyRef.current !== targetKey) return;
634
657
  localEditRevision.current += 1;
635
- setRestoredResources((current) => current.filter((_, candidate) => candidate !== index));
658
+ const next = restoredResourcesRef.current.filter((_, candidate) => candidate !== index);
659
+ restoredResourcesRef.current = next;
660
+ setRestoredResources(next);
636
661
  },
637
662
  [targetKey],
638
663
  );
639
664
 
665
+ const hasDraftContent = useCallback((): boolean => {
666
+ const current = draftRef.current;
667
+ const extras = resolveSendExtras(sendExtrasRef.current);
668
+ const toolsProvidedByHost = Object.prototype.hasOwnProperty.call(extras, "tools");
669
+ const tools = toolsProvidedByHost ? (extras.tools ?? []) : (current?.tools ?? []);
670
+ return (
671
+ valueRef.current.length > 0 ||
672
+ restoredResourcesRef.current.length > 0 ||
673
+ (extras.resources?.length ?? 0) > 0 ||
674
+ tools.length > 0 ||
675
+ (current?.sourceTurnId !== null && current?.sourceTurnId !== undefined)
676
+ );
677
+ }, []);
678
+
640
679
  const resolveDraftConflict = useCallback(
641
680
  async (choice: "keep_mine" | "use_remote"): Promise<void> => {
642
681
  const ownedTargetKey = targetKey;
@@ -679,6 +718,7 @@ export function useComposer(
679
718
  return {
680
719
  value: identityMatches ? value : "",
681
720
  setValue: updateValue,
721
+ hasDraftContent,
682
722
  send,
683
723
  steer,
684
724
  sending: identityMatches ? sending : false,
@@ -686,6 +726,7 @@ export function useComposer(
686
726
  identityMatches &&
687
727
  Boolean(sessionId) &&
688
728
  !sending &&
729
+ sendBlockedRef.current?.() !== true &&
689
730
  (value.trim().length > 0 || hasReadyResources),
690
731
  pause,
691
732
  pausing: identityMatches ? pausing : false,
@@ -774,11 +815,30 @@ function draftPayload(draft: ComposerDraft): SaveComposerDraftRequest {
774
815
  text: draft.text,
775
816
  resources: draft.resources,
776
817
  tools: draft.tools,
818
+ toolsProvided: draft.toolsProvided,
777
819
  model: draft.model,
778
820
  reasoningEffort: draft.reasoningEffort,
779
821
  };
780
822
  }
781
823
 
824
+ function composerDraftPayload(
825
+ base: ComposerDraft,
826
+ text: string,
827
+ restoredResources: ResourceRef[],
828
+ extras: ComposerSendExtras,
829
+ ): SaveComposerDraftRequest {
830
+ const toolsProvidedByHost = Object.prototype.hasOwnProperty.call(extras, "tools");
831
+ return {
832
+ expectedRevision: base.revision,
833
+ text,
834
+ resources: mergeResources(restoredResources, extras.resources ?? []),
835
+ tools: toolsProvidedByHost ? (extras.tools ?? []) : base.tools,
836
+ toolsProvided: toolsProvidedByHost ? true : base.toolsProvided,
837
+ model: extras.model ?? base.model,
838
+ reasoningEffort: extras.reasoningEffort ?? base.reasoningEffort,
839
+ };
840
+ }
841
+
782
842
  function draftSignature(payload: SaveComposerDraftRequest): string {
783
843
  const { expectedRevision: _revision, ...content } = payload;
784
844
  return JSON.stringify(content);
@@ -33,12 +33,20 @@ export type UseFileAttachmentsResult = {
33
33
  * straight into `useComposer`'s `sendExtras.resources`.
34
34
  */
35
35
  readyResources: FileResourceRef[];
36
- /** True while any attachment is still uploading (drives the send-gate). */
36
+ /** True while any attachment is still uploading (drives progress UI). */
37
37
  uploading: boolean;
38
+ /**
39
+ * True while any attachment still needs an explicit outcome: wait for an
40
+ * upload, retry a failure, or remove it. This is the loss-prevention send
41
+ * gate; failed cards must never be silently omitted from a message.
42
+ */
43
+ hasUnresolved: boolean;
38
44
  /** Explicit picker / drop path — uploads every file, no filter. */
39
45
  addFiles: (files: Iterable<File>) => void;
40
46
  /** Clipboard path — applies `pasteFilter` (default `image/*`) then uploads. */
41
47
  addFromPaste: (event: { clipboardData: DataTransfer | null }) => void;
48
+ /** Restore already-ready server assets without recreating browser-local bytes. */
49
+ restoreReadyFiles: (files: Iterable<FileAsset>) => void;
42
50
  /**
43
51
  * Re-run the upload for a `failed` attachment, in place (same id, same
44
52
  * source file). No-op for an id that isn't a known failed upload.
@@ -46,7 +54,13 @@ export type UseFileAttachmentsResult = {
46
54
  retry: (id: string) => void;
47
55
  /** Remove one attachment; revokes its object-URL. */
48
56
  remove: (id: string) => void;
49
- /** Remove all; revokes every object-URL. Call from `useComposer`'s `onSent`. */
57
+ /**
58
+ * Remove only finalized files whose durable ids were accepted by a send.
59
+ * Attachments added while that request was in flight remain queued for the
60
+ * next message.
61
+ */
62
+ removeReadyFiles: (fileIds: Iterable<string>) => void;
63
+ /** Remove all attachments and revoke every object-URL. */
50
64
  clear: () => void;
51
65
  };
52
66
 
@@ -83,6 +97,10 @@ export function useFileAttachments(
83
97
  data: file,
84
98
  })
85
99
  .then((asset) => {
100
+ // Retry bytes are useful only until durable finalization succeeds.
101
+ // Drop the source File immediately; restored/ready attachments must
102
+ // never retain browser-local byte authority.
103
+ sources.current.delete(id);
86
104
  setAttachments((current) =>
87
105
  current.map((attachment) =>
88
106
  attachment.id === id
@@ -171,6 +189,60 @@ export function useFileAttachments(
171
189
  [addFiles, pasteFilter],
172
190
  );
173
191
 
192
+ const restoreReadyFiles = useCallback(
193
+ (files: Iterable<FileAsset>) => {
194
+ const incoming = new Map<string, FileAsset>();
195
+ for (const file of files) {
196
+ if (file.status === "ready" && file.workspaceId === workspaceId) {
197
+ incoming.set(file.id, file);
198
+ }
199
+ }
200
+ setAttachments((current) => {
201
+ const unresolved = current.filter((attachment) => attachment.status !== "ready");
202
+ const existingReady = new Map(
203
+ current.flatMap((attachment) =>
204
+ attachment.status === "ready" && attachment.file
205
+ ? ([[attachment.file.id, attachment]] as const)
206
+ : [],
207
+ ),
208
+ );
209
+ const restored = [...incoming.values()].map((file): FileAttachment => {
210
+ const existing = existingReady.get(file.id);
211
+ return existing
212
+ ? {
213
+ ...existing,
214
+ name: file.filename,
215
+ contentType: file.contentType,
216
+ sizeBytes: file.sizeBytes,
217
+ status: "ready",
218
+ file,
219
+ error: undefined,
220
+ }
221
+ : {
222
+ id: `restored:${file.id}`,
223
+ name: file.filename,
224
+ contentType: file.contentType,
225
+ sizeBytes: file.sizeBytes,
226
+ status: "ready",
227
+ file,
228
+ // No source File and no object URL: server metadata is the
229
+ // only authority restored across page/device boundaries.
230
+ };
231
+ });
232
+ for (const [fileId, attachment] of existingReady) {
233
+ if (!incoming.has(fileId) && attachment.previewUrl) {
234
+ URL.revokeObjectURL(attachment.previewUrl);
235
+ }
236
+ }
237
+ // A server restoration is authoritative for finalized assets, but an
238
+ // upload that has not finalized still belongs to the local actor. Keep
239
+ // those unresolved entries while replacing the ready set exactly.
240
+ return [...unresolved, ...restored];
241
+ });
242
+ },
243
+ [workspaceId],
244
+ );
245
+
174
246
  const remove = useCallback((id: string) => {
175
247
  sources.current.delete(id);
176
248
  setAttachments((current) => {
@@ -182,6 +254,24 @@ export function useFileAttachments(
182
254
  });
183
255
  }, []);
184
256
 
257
+ const removeReadyFiles = useCallback((fileIds: Iterable<string>) => {
258
+ const accepted = new Set(fileIds);
259
+ if (accepted.size === 0) return;
260
+ setAttachments((current) =>
261
+ current.filter((attachment) => {
262
+ const removeAccepted =
263
+ attachment.status === "ready" &&
264
+ attachment.file !== undefined &&
265
+ accepted.has(attachment.file.id);
266
+ if (removeAccepted) {
267
+ sources.current.delete(attachment.id);
268
+ if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
269
+ }
270
+ return !removeAccepted;
271
+ }),
272
+ );
273
+ }, []);
274
+
185
275
  const clear = useCallback(() => {
186
276
  sources.current.clear();
187
277
  setAttachments((current) => {
@@ -202,10 +292,13 @@ export function useFileAttachments(
202
292
  : [],
203
293
  ),
204
294
  uploading: attachments.some((attachment) => attachment.status === "uploading"),
295
+ hasUnresolved: attachments.some((attachment) => attachment.status !== "ready"),
205
296
  addFiles,
206
297
  addFromPaste,
298
+ restoreReadyFiles,
207
299
  retry,
208
300
  remove,
301
+ removeReadyFiles,
209
302
  clear,
210
303
  };
211
304
  }
@@ -13,9 +13,26 @@ export function isGoalEvent(event: Pick<SessionEvent, "type">): boolean {
13
13
  return event.type.startsWith("goal.");
14
14
  }
15
15
 
16
+ /**
17
+ * Events that can change the server-authoritative continuation projection.
18
+ * The projection is derived from durable turn, session, control, and system
19
+ * update state, so those events refresh the goal without model polling.
20
+ */
21
+ export function isGoalRefreshEvent(event: Pick<SessionEvent, "type">): boolean {
22
+ const type = event.type;
23
+ return (
24
+ isGoalEvent(event) ||
25
+ type.startsWith("turn.") ||
26
+ type.startsWith("session.") ||
27
+ type.startsWith("system.update.") ||
28
+ type.startsWith("workspace.inference.") ||
29
+ type.startsWith("user.")
30
+ );
31
+ }
32
+
16
33
  export type UseGoalOptions = ClientOverride &
17
34
  SessionEventFeedOptions & {
18
- /** Optional safety-net polling (ms). Off by default — goal.* events drive updates. */
35
+ /** Optional safety-net polling (ms). Off by default — event refreshes drive updates. */
19
36
  pollIntervalMs?: number | undefined;
20
37
  };
21
38
 
@@ -46,8 +63,9 @@ export type UseGoalResult = {
46
63
  /**
47
64
  * The session's goal: state, the autonomy counters (`autoContinuations`,
48
65
  * `noProgressStreak`), and pause/resume control. A goal-less session yields
49
- * `goal: null` (the 404 is absorbed). Live-updates on `goal.*` events
50
- * pass `options.events` from `useSessionEvents` to reuse its stream.
66
+ * `goal: null` (the 404 is absorbed). Live-updates on goal, turn, session,
67
+ * control, and system-update events — pass `options.events` from
68
+ * `useSessionEvents` to reuse its stream.
51
69
  */
52
70
  export function useGoal(
53
71
  sessionId: string | null | undefined,
@@ -124,7 +142,7 @@ export function useGoal(
124
142
  }, [load, enabled, workspaceId, sessionId, options.pollIntervalMs, sharedFeed]);
125
143
 
126
144
  const scheduleRefresh = useDebouncedCallback(() => void load());
127
- useSessionEventTrigger(client, workspaceId, sessionId, isGoalEvent, scheduleRefresh, {
145
+ useSessionEventTrigger(client, workspaceId, sessionId, isGoalRefreshEvent, scheduleRefresh, {
128
146
  enabled,
129
147
  ...(sharedEvents !== undefined ? { events: sharedEvents } : {}),
130
148
  });