@opengeni/react 0.13.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 (113) hide show
  1. package/README.md +129 -23
  2. package/dist/chunk-6BXBGJ3B.js +1956 -0
  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 +642 -1080
  21. package/dist/index.js +7672 -8005
  22. package/dist/index.js.map +1 -1
  23. package/dist/machines.d.ts +412 -3
  24. package/dist/machines.js +25 -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 +23 -4
  38. package/src/approvals.ts +5 -3
  39. package/src/client.ts +50 -1
  40. package/src/components/approval-surface.tsx +198 -0
  41. package/src/components/chat-composer.tsx +104 -751
  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 +82 -16
  51. package/src/components/machine-health-pill.tsx +68 -0
  52. package/src/components/machine-metrics.tsx +10 -24
  53. package/src/components/machines/health.ts +146 -0
  54. package/src/components/machines/machine-detail.tsx +220 -0
  55. package/src/components/machines/metric-history-chart.tsx +298 -0
  56. package/src/components/machines/metric-sparkline.tsx +76 -0
  57. package/src/components/machines/series.ts +113 -0
  58. package/src/components/machines-dashboard.tsx +16 -4
  59. package/src/components/message-timeline.tsx +15 -7
  60. package/src/components/model-picker.tsx +12 -3
  61. package/src/components/pierre-diff.tsx +32 -6
  62. package/src/components/queue-surface-implementation.tsx +1041 -0
  63. package/src/components/queue-surface-state.tsx +94 -0
  64. package/src/components/queue-surface.tsx +60 -0
  65. package/src/components/sandbox-files.tsx +230 -21
  66. package/src/components/sandbox-terminal.tsx +8 -2
  67. package/src/components/sandbox-workspace.tsx +489 -135
  68. package/src/components/session-status.tsx +0 -6
  69. package/src/components/workbench-changes.tsx +145 -50
  70. package/src/components/workspace-dock.tsx +329 -68
  71. package/src/composer.ts +60 -0
  72. package/src/hooks/internal.ts +115 -34
  73. package/src/hooks/use-composer.ts +635 -74
  74. package/src/hooks/use-human-input.ts +131 -0
  75. package/src/hooks/use-machine-chip.ts +2 -2
  76. package/src/hooks/use-machines.ts +27 -11
  77. package/src/hooks/use-sandbox-files.ts +310 -62
  78. package/src/hooks/use-sandbox-git.ts +154 -40
  79. package/src/hooks/use-sandbox-terminal.ts +28 -6
  80. package/src/hooks/use-session-capabilities.ts +38 -9
  81. package/src/hooks/use-session-control.ts +43 -15
  82. package/src/hooks/use-session-events.ts +523 -48
  83. package/src/hooks/use-session-lineage.ts +15 -6
  84. package/src/hooks/use-session.ts +10 -2
  85. package/src/hooks/use-slash-commands.ts +38 -38
  86. package/src/hooks/use-transcription.ts +757 -0
  87. package/src/hooks/use-turn-queue.ts +273 -77
  88. package/src/hooks/use-windowed-sections.ts +2 -2
  89. package/src/hooks/use-workspace-capture.ts +146 -40
  90. package/src/hooks/use-workspace-edit.ts +50 -14
  91. package/src/hooks/use-workspace-sessions.ts +3 -0
  92. package/src/human-input.ts +72 -0
  93. package/src/index.ts +88 -12
  94. package/src/lib/noto-sans-arabic-loader.ts +21 -0
  95. package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
  96. package/src/lib/use-portal-token-style.ts +51 -0
  97. package/src/lib/use-unicode-fonts.ts +57 -0
  98. package/src/machines.ts +16 -0
  99. package/src/provider.tsx +163 -41
  100. package/src/session-context.ts +135 -0
  101. package/src/session.ts +79 -0
  102. package/src/timeline/parsers.ts +78 -6
  103. package/src/timeline/projection.ts +36 -6
  104. package/src/timeline/tool-renderers.tsx +41 -0
  105. package/src/timeline/turn-summary.tsx +2 -2
  106. package/src/timeline/types.ts +16 -8
  107. package/styles/index.css +78 -0
  108. package/styles/index.d.ts +2 -0
  109. package/styles/tokens.css +11 -6
  110. package/styles/tokens.d.ts +2 -0
  111. package/dist/chunk-NFYVQWIB.js +0 -1377
  112. package/dist/chunk-NFYVQWIB.js.map +0 -1
  113. package/dist/machines-CnlMb7E-.d.ts +0 -329
@@ -1,38 +1,79 @@
1
- import type { SendMessageInput } from "@opengeni/sdk";
2
- import { useCallback, useEffect, useRef, useState } from "react";
3
- import { useOpenGeni, type ClientOverride } from "../provider";
1
+ import type {
2
+ ComposerDraft,
3
+ EffectiveControlResumeOption,
4
+ EffectiveSessionControl,
5
+ ResourceRef,
6
+ SaveComposerDraftRequest,
7
+ SendMessageInput,
8
+ SessionEvent,
9
+ } from "@opengeni/sdk";
10
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
11
+ import { useEmbeddedSession, type EmbeddedSessionClientOverride } from "../session-context";
12
+ import { useSessionEventTrigger, type SessionEventFeedOptions } from "./internal";
4
13
 
5
14
  export type ComposerSendExtras = Omit<SendMessageInput, "text" | "clientEventId">;
6
15
 
7
- /**
8
- * Enter appends a prompt; Cmd/Ctrl+Enter steers it to the head and supersedes
9
- * the current inference.
10
- */
11
- export type ComposerMode = "queue" | "steer";
12
-
13
- export type UseComposerOptions = ClientOverride & {
14
- /** Called with the accepted text after a successful send. */
15
- onSent?: ((text: string) => void) | undefined;
16
- /**
17
- * Extra message fields (resources, tools, model, reasoningEffort) merged
18
- * into every send. A function is evaluated at send time so it can read the
19
- * surrounding UI state (attachment pickers, model selectors, ...).
20
- */
21
- sendExtras?: ComposerSendExtras | (() => ComposerSendExtras) | undefined;
22
- };
16
+ export type UseComposerOptions = EmbeddedSessionClientOverride &
17
+ SessionEventFeedOptions & {
18
+ /** Called with the accepted text after a successful send. */
19
+ onSent?: ((text: string) => void) | undefined;
20
+ /**
21
+ * Extra message fields (resources, tools, model, reasoningEffort) merged
22
+ * into every send. A function is evaluated at send time so it can read the
23
+ * surrounding UI state (attachment pickers, model selectors, ...).
24
+ */
25
+ sendExtras?: ComposerSendExtras | (() => ComposerSendExtras) | undefined;
26
+ /** Latest server-derived workstream control; bound into Send/Steer OCC. */
27
+ effectiveControl?: EffectiveSessionControl | null | undefined;
28
+ /** Apply durable model/tool/reasoning settings in the host's controlled UI. */
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;
42
+ };
23
43
 
24
44
  export type ComposerState = {
25
45
  value: string;
26
46
  setValue: (value: string) => void;
27
- /** Send the draft (or explicit text). Queue is the default; steer is explicit. */
28
- send: (text?: string, mode?: ComposerMode) => Promise<boolean>;
47
+ /** Append the draft behind prompts already visible in the queue. */
48
+ send: (text?: string) => Promise<boolean>;
49
+ /** Supersede current direction with the draft. */
50
+ steer: (text?: string) => Promise<boolean>;
29
51
  sending: boolean;
30
52
  canSend: boolean;
31
53
  /** Pause the session without deleting its prompt queue. */
32
54
  pause: (reason?: string) => Promise<void>;
33
55
  pausing: boolean;
34
56
  resume: (reason?: string) => Promise<void>;
57
+ resumeScope: (option: EffectiveControlResumeOption) => Promise<void>;
35
58
  resuming: boolean;
59
+ draft: ComposerDraft | null;
60
+ draftRevision: number;
61
+ draftLoading: boolean;
62
+ draftSaving: boolean;
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;
71
+ /** Apply an atomic queue Edit checkout without a second read. */
72
+ applyDraft: (draft: ComposerDraft) => void;
73
+ reloadDraft: () => Promise<void>;
74
+ resolveDraftConflict: (choice: "keep_mine" | "use_remote") => Promise<void>;
75
+ restoredResources: ResourceRef[];
76
+ removeRestoredResource: (index: number) => void;
36
77
  error: Error | null;
37
78
  clearError: () => void;
38
79
  };
@@ -47,41 +88,304 @@ export function useComposer(
47
88
  sessionId: string | null | undefined,
48
89
  options: UseComposerOptions = {},
49
90
  ): ComposerState {
50
- const { client, workspaceId } = 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"}`;
51
94
  const [value, setValue] = useState("");
95
+ const [stateTargetKey, setStateTargetKey] = useState(targetKey);
52
96
  const [sending, setSending] = useState(false);
53
97
  const [pausing, setPausing] = useState(false);
54
98
  const [resuming, setResuming] = useState(false);
55
99
  const [error, setError] = useState<Error | null>(null);
100
+ const [draft, setDraft] = useState<ComposerDraft | null>(null);
101
+ const [draftLoading, setDraftLoading] = useState(Boolean(sessionId) && durableDrafts);
102
+ const [draftSaving, setDraftSaving] = useState(false);
103
+ const [draftConflict, setDraftConflict] = useState<Error | null>(null);
104
+ const [restoredResources, setRestoredResources] = useState<ResourceRef[]>([]);
56
105
  const pendingClientEventId = useRef<string | null>(null);
106
+ const draftRef = useRef<ComposerDraft | null>(null);
107
+ const draftLoadErrorRef = useRef<Error | null>(null);
108
+ const draftReadGeneration = useRef(0);
109
+ const localEditRevision = useRef(0);
110
+ const targetGeneration = useRef(0);
111
+ const lastSavedSignature = useRef<string | null>(null);
112
+ const saveChain = useRef<Promise<void>>(Promise.resolve());
57
113
  const onSent = options.onSent;
114
+ const onDraftApplied = options.onDraftApplied;
58
115
  // Read through a ref so a new extras closure (created every render by
59
116
  // callers passing inline functions) does not invalidate `send`.
60
117
  const sendExtrasRef = useRef(options.sendExtras);
61
- sendExtrasRef.current = options.sendExtras;
118
+ useLayoutEffect(() => {
119
+ sendExtrasRef.current = options.sendExtras;
120
+ }, [options.sendExtras]);
121
+ const liveExtras = resolveSendExtras(options.sendExtras);
122
+ const liveExtrasVersion = JSON.stringify(liveExtras);
62
123
 
63
124
  // A composer is bound to one session: switching targets must not leak the
64
- // previous session's draft, error, or retry idempotency key.
65
- 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.
66
128
  const targetKeyRef = useRef(targetKey);
67
- useEffect(() => {
68
- if (targetKeyRef.current !== targetKey) {
69
- targetKeyRef.current = 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]);
154
+
155
+ const applyDraft = useCallback(
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
+ }
169
+ draftRef.current = next;
170
+ lastSavedSignature.current = draftSignature(draftPayload(next));
171
+ localEditRevision.current += 1;
70
172
  pendingClientEventId.current = null;
71
- setValue("");
72
- setError(null);
173
+ setDraft(next);
174
+ setValue(next.text);
175
+ setRestoredResources(next.resources);
176
+ setDraftConflict(null);
177
+ onDraftApplied?.(next);
178
+ },
179
+ [durableDrafts, onDraftApplied, targetKey],
180
+ );
181
+
182
+ const loadDraft = useCallback(
183
+ async (replaceLocal: boolean, rejectOnFailure = false): Promise<void> => {
184
+ if (targetKeyRef.current !== targetKey) return;
185
+ if (!sessionId || !durableDrafts) {
186
+ setDraftLoading(false);
187
+ return;
188
+ }
189
+ const generation = targetGeneration.current;
190
+ const readTicket = ++draftReadGeneration.current;
191
+ const localAtStart = localEditRevision.current;
192
+ setDraftLoading(true);
193
+ try {
194
+ const fetched = await client.getComposerDraft(workspaceId, sessionId);
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
+ }
221
+ }
222
+ } catch (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;
233
+ } finally {
234
+ if (
235
+ generation === targetGeneration.current &&
236
+ targetKeyRef.current === targetKey &&
237
+ readTicket === draftReadGeneration.current
238
+ ) {
239
+ setDraftLoading(false);
240
+ }
241
+ }
242
+ },
243
+ [client, durableDrafts, onDraftApplied, sessionId, targetKey, workspaceId],
244
+ );
245
+
246
+ useEffect(() => {
247
+ if (!sessionId || !durableDrafts) {
248
+ setDraftLoading(false);
249
+ return;
73
250
  }
74
- }, [targetKey]);
251
+ void loadDraft(false);
252
+ }, [durableDrafts, loadDraft, sessionId]);
253
+ useEffect(() => {
254
+ if (!sessionId || !durableDrafts) return;
255
+ return registerSessionReconciler(sessionId, "composer", async () => await loadDraft(false));
256
+ }, [durableDrafts, loadDraft, registerSessionReconciler, sessionId]);
257
+ useSessionEventTrigger(
258
+ client,
259
+ workspaceId,
260
+ sessionId,
261
+ isComposerDraftEvent,
262
+ () => void loadDraft(false),
263
+ {
264
+ enabled: Boolean(sessionId) && durableDrafts,
265
+ ...(options.events !== undefined ? { events: options.events } : {}),
266
+ },
267
+ async () => await loadDraft(false, true),
268
+ );
75
269
 
76
- const send = useCallback(
77
- async (explicit?: string, delivery: ComposerMode = "queue"): Promise<boolean> => {
270
+ const currentDraftPayload = useCallback((): SaveComposerDraftRequest | null => {
271
+ if (targetKeyRef.current !== targetKey) return null;
272
+ const base = draftRef.current;
273
+ if (!base) return null;
274
+ 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]);
284
+
285
+ const persistPayload = useCallback(
286
+ async (payload: SaveComposerDraftRequest): Promise<boolean> => {
287
+ const ownedTargetKey = targetKey;
288
+ const ownedGeneration = targetGeneration.current;
289
+ if (!sessionId || targetKeyRef.current !== ownedTargetKey) return false;
290
+ let success = false;
291
+ const run = async () => {
292
+ if (
293
+ targetKeyRef.current !== ownedTargetKey ||
294
+ targetGeneration.current !== ownedGeneration
295
+ ) {
296
+ return;
297
+ }
298
+ const current = draftRef.current;
299
+ if (!current) return;
300
+ const request = { ...payload, expectedRevision: current.revision };
301
+ const signature = draftSignature(request);
302
+ if (signature === lastSavedSignature.current) {
303
+ success = true;
304
+ return;
305
+ }
306
+ setDraftSaving(true);
307
+ try {
308
+ const saved = await client.saveComposerDraft(workspaceId, sessionId, request);
309
+ if (
310
+ targetKeyRef.current !== ownedTargetKey ||
311
+ targetGeneration.current !== ownedGeneration
312
+ ) {
313
+ return;
314
+ }
315
+ draftRef.current = saved;
316
+ setDraft(saved);
317
+ lastSavedSignature.current = signature;
318
+ setDraftConflict(null);
319
+ success = true;
320
+ } catch (cause) {
321
+ if (
322
+ targetKeyRef.current === ownedTargetKey &&
323
+ targetGeneration.current === ownedGeneration
324
+ ) {
325
+ const problem = asError(cause);
326
+ setDraftConflict(problem);
327
+ setError(problem);
328
+ }
329
+ } finally {
330
+ if (
331
+ targetKeyRef.current === ownedTargetKey &&
332
+ targetGeneration.current === ownedGeneration
333
+ ) {
334
+ setDraftSaving(false);
335
+ }
336
+ }
337
+ };
338
+ saveChain.current = saveChain.current.then(run, run);
339
+ await saveChain.current;
340
+ return success;
341
+ },
342
+ [client, sessionId, targetKey, workspaceId],
343
+ );
344
+
345
+ // Private durable autosave. A newer local edit is never replaced by an older
346
+ // response; saves serialize and each reads the latest acknowledged revision.
347
+ useEffect(() => {
348
+ if (
349
+ !durableDrafts ||
350
+ !sessionId ||
351
+ draftLoading ||
352
+ sending ||
353
+ !draftRef.current ||
354
+ draftConflict
355
+ )
356
+ return;
357
+ const payload = currentDraftPayload();
358
+ if (!payload || draftSignature(payload) === lastSavedSignature.current) return;
359
+ const timer = window.setTimeout(() => void persistPayload(payload), 500);
360
+ return () => window.clearTimeout(timer);
361
+ }, [
362
+ currentDraftPayload,
363
+ durableDrafts,
364
+ draftConflict,
365
+ draftLoading,
366
+ liveExtrasVersion,
367
+ persistPayload,
368
+ sending,
369
+ sessionId,
370
+ ]);
371
+
372
+ const dispatch = useCallback(
373
+ async (delivery: "send" | "steer", explicit?: string): Promise<boolean> => {
374
+ const ownedTargetKey = targetKey;
375
+ const ownedGeneration = targetGeneration.current;
78
376
  const draftAtSend = value;
79
- const text = (explicit ?? draftAtSend).trim();
377
+ const rawText = explicit ?? draftAtSend;
378
+ const hasText = rawText.trim().length > 0;
80
379
  // Resolve the extras once: a file-only message (empty text + ≥1 ready
81
380
  // resource) is legitimate, so we must not bail on empty text alone.
82
381
  const extras = resolveSendExtras(sendExtrasRef.current);
83
- const hasResources = (extras.resources?.length ?? 0) > 0;
84
- if ((!text && !hasResources) || !sessionId || sending) {
382
+ const hasResources = restoredResources.length > 0 || (extras.resources?.length ?? 0) > 0;
383
+ if (
384
+ (!hasText && !hasResources) ||
385
+ !sessionId ||
386
+ sending ||
387
+ targetKeyRef.current !== ownedTargetKey
388
+ ) {
85
389
  return false;
86
390
  }
87
391
  // Reuse the clientEventId across retries of the same draft so a
@@ -90,17 +394,56 @@ export function useComposer(
90
394
  setSending(true);
91
395
  setError(null);
92
396
  try {
93
- // The wire contract requires non-empty text (z.string().min(1)) and the
94
- // worker rejects whitespace-only text; a file-only message therefore
95
- // carries a minimal default so the attachments still get delivered.
96
- const sendText = text || FILE_ONLY_MESSAGE_TEXT;
97
- const input = composeSendInput(sendText, pendingClientEventId.current, extras);
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;
405
+ if (payload && !(await persistPayload(payload))) return false;
406
+ if (
407
+ targetKeyRef.current !== ownedTargetKey ||
408
+ targetGeneration.current !== ownedGeneration
409
+ ) {
410
+ return false;
411
+ }
412
+ const input = composeSendInput(sendText, pendingClientEventId.current, extras, {
413
+ ...(options.effectiveControl?.controlEtag
414
+ ? { controlEtag: options.effectiveControl.controlEtag }
415
+ : {}),
416
+ ...(draftRef.current ? { expectedDraftRevision: draftRef.current.revision } : {}),
417
+ resources: mergeResources(restoredResources, extras.resources ?? []),
418
+ });
98
419
  if (delivery === "steer") {
99
420
  await client.steerMessage(workspaceId, sessionId, input);
100
421
  } else {
101
422
  await client.sendMessage(workspaceId, sessionId, input);
102
423
  }
424
+ if (
425
+ targetKeyRef.current !== ownedTargetKey ||
426
+ targetGeneration.current !== ownedGeneration
427
+ ) {
428
+ return false;
429
+ }
103
430
  pendingClientEventId.current = null;
431
+ const previousDraft = draftRef.current;
432
+ if (previousDraft) {
433
+ const cleared = {
434
+ ...previousDraft,
435
+ revision: 0,
436
+ text: "",
437
+ resources: [],
438
+ sourceTurnId: null,
439
+ sourceTurnVersion: null,
440
+ updatedAt: null,
441
+ };
442
+ draftRef.current = cleared;
443
+ setDraft(cleared);
444
+ setRestoredResources([]);
445
+ lastSavedSignature.current = draftSignature(draftPayload(cleared));
446
+ }
104
447
  if (explicit === undefined) {
105
448
  // Clear only the draft that was sent: edits made while the request
106
449
  // was in flight were never delivered and must survive.
@@ -109,76 +452,267 @@ export function useComposer(
109
452
  onSent?.(sendText);
110
453
  return true;
111
454
  } catch (cause) {
112
- 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
+ }
113
461
  return false;
114
462
  } finally {
115
- setSending(false);
463
+ if (
464
+ targetKeyRef.current === ownedTargetKey &&
465
+ targetGeneration.current === ownedGeneration
466
+ ) {
467
+ setSending(false);
468
+ }
116
469
  }
117
470
  },
118
- [client, workspaceId, sessionId, value, sending, onSent],
471
+ [
472
+ client,
473
+ currentDraftPayload,
474
+ onSent,
475
+ options.effectiveControl?.controlEtag,
476
+ persistPayload,
477
+ restoredResources,
478
+ sending,
479
+ sessionId,
480
+ targetKey,
481
+ value,
482
+ workspaceId,
483
+ ],
119
484
  );
120
485
 
486
+ const send = useCallback(async (text?: string) => await dispatch("send", text), [dispatch]);
487
+ const steer = useCallback(async (text?: string) => await dispatch("steer", text), [dispatch]);
488
+
121
489
  // A send is possible with non-empty text OR with ≥1 attached resource (a
122
490
  // file-only message). Resources ride in `sendExtras`, so we resolve them here
123
491
  // — keeping useComposer attachment-agnostic while still lighting up the send
124
492
  // affordance the moment a file is ready. ChatComposer additionally gates this
125
493
  // on its `attachments.uploading` flag so a message never departs mid-upload.
126
- const hasReadyResources = (resolveSendExtras(sendExtrasRef.current).resources?.length ?? 0) > 0;
494
+ const hasReadyResources = restoredResources.length > 0 || (liveExtras.resources?.length ?? 0) > 0;
127
495
 
128
496
  const pause = useCallback(
129
497
  async (reason?: string): Promise<void> => {
130
- if (!sessionId || pausing) {
498
+ const ownedTargetKey = targetKey;
499
+ const ownedGeneration = targetGeneration.current;
500
+ if (!sessionId || pausing || targetKeyRef.current !== ownedTargetKey) {
131
501
  return;
132
502
  }
133
503
  setPausing(true);
134
504
  setError(null);
135
505
  try {
136
- await client.pauseSession(workspaceId, sessionId, reason !== undefined ? { reason } : {});
506
+ await client.pauseSession(workspaceId, sessionId, {
507
+ ...(reason !== undefined ? { reason } : {}),
508
+ ...(options.effectiveControl?.controlEtag
509
+ ? { expectedControlEtag: options.effectiveControl.controlEtag }
510
+ : {}),
511
+ });
137
512
  } catch (cause) {
138
- 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
+ }
139
519
  } finally {
140
- setPausing(false);
520
+ if (
521
+ targetKeyRef.current === ownedTargetKey &&
522
+ targetGeneration.current === ownedGeneration
523
+ ) {
524
+ setPausing(false);
525
+ }
141
526
  }
142
527
  },
143
- [client, workspaceId, sessionId, pausing],
528
+ [client, workspaceId, sessionId, pausing, options.effectiveControl?.controlEtag, targetKey],
144
529
  );
145
530
 
146
531
  const resume = useCallback(
147
532
  async (reason?: string): Promise<void> => {
148
- if (!sessionId || resuming) return;
533
+ const ownedTargetKey = targetKey;
534
+ const ownedGeneration = targetGeneration.current;
535
+ if (!sessionId || resuming || targetKeyRef.current !== ownedTargetKey) return;
149
536
  setResuming(true);
150
537
  setError(null);
151
538
  try {
152
- await client.resumeSession(workspaceId, sessionId, reason !== undefined ? { reason } : {});
539
+ await client.resumeSession(workspaceId, sessionId, {
540
+ ...(reason !== undefined ? { reason } : {}),
541
+ ...(options.effectiveControl?.controlEtag
542
+ ? { expectedControlEtag: options.effectiveControl.controlEtag }
543
+ : {}),
544
+ });
153
545
  } catch (cause) {
154
- 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
+ }
155
552
  } finally {
156
- setResuming(false);
553
+ if (
554
+ targetKeyRef.current === ownedTargetKey &&
555
+ targetGeneration.current === ownedGeneration
556
+ ) {
557
+ setResuming(false);
558
+ }
157
559
  }
158
560
  },
159
- [client, workspaceId, sessionId, resuming],
561
+ [client, workspaceId, sessionId, resuming, options.effectiveControl?.controlEtag, targetKey],
160
562
  );
161
563
 
162
- const updateValue = useCallback((next: string) => {
163
- pendingClientEventId.current = null;
164
- setValue(next);
165
- }, []);
564
+ const resumeScope = useCallback(
565
+ async (option: EffectiveControlResumeOption): Promise<void> => {
566
+ const ownedTargetKey = targetKey;
567
+ const ownedGeneration = targetGeneration.current;
568
+ if (!sessionId || resuming || targetKeyRef.current !== ownedTargetKey) return;
569
+ setResuming(true);
570
+ setError(null);
571
+ try {
572
+ if (option.scope === "workspace") {
573
+ if (!client.setWorkspaceInferenceState) {
574
+ throw new Error("Workspace-level resume is not available through this client");
575
+ }
576
+ const workspaceBlocker = options.effectiveControl?.blockers.find(
577
+ (blocker) => blocker.kind === "workspace",
578
+ );
579
+ await client.setWorkspaceInferenceState(workspaceId, {
580
+ action: "resume",
581
+ clientEventId: generateClientEventId(),
582
+ ...(workspaceBlocker ? { expectedRevision: workspaceBlocker.revision } : {}),
583
+ });
584
+ } else if (option.scope === "session" && option.targetId) {
585
+ const target = await client.getQueue(workspaceId, option.targetId);
586
+ if (
587
+ targetKeyRef.current !== ownedTargetKey ||
588
+ targetGeneration.current !== ownedGeneration
589
+ ) {
590
+ return;
591
+ }
592
+ await client.resumeSession(workspaceId, option.targetId, {
593
+ expectedControlEtag: target.effectiveControl.controlEtag,
594
+ });
595
+ } else {
596
+ await client.resumeSession(workspaceId, sessionId, {
597
+ ...(options.effectiveControl?.controlEtag
598
+ ? { expectedControlEtag: options.effectiveControl.controlEtag }
599
+ : {}),
600
+ });
601
+ }
602
+ } catch (cause) {
603
+ if (
604
+ targetKeyRef.current === ownedTargetKey &&
605
+ targetGeneration.current === ownedGeneration
606
+ ) {
607
+ setError(asError(cause));
608
+ }
609
+ } finally {
610
+ if (
611
+ targetKeyRef.current === ownedTargetKey &&
612
+ targetGeneration.current === ownedGeneration
613
+ ) {
614
+ setResuming(false);
615
+ }
616
+ }
617
+ },
618
+ [client, options.effectiveControl, resuming, sessionId, targetKey, workspaceId],
619
+ );
620
+
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
+ );
630
+
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
+ );
639
+
640
+ const resolveDraftConflict = useCallback(
641
+ async (choice: "keep_mine" | "use_remote"): Promise<void> => {
642
+ const ownedTargetKey = targetKey;
643
+ const ownedGeneration = targetGeneration.current;
644
+ if (!sessionId || !durableDrafts || targetKeyRef.current !== ownedTargetKey) return;
645
+ const remote = await client.getComposerDraft(workspaceId, sessionId);
646
+ if (targetKeyRef.current !== ownedTargetKey || targetGeneration.current !== ownedGeneration) {
647
+ return;
648
+ }
649
+ if (choice === "use_remote") {
650
+ applyDraft(remote);
651
+ return;
652
+ }
653
+ draftRef.current = remote;
654
+ setDraft(remote);
655
+ setDraftConflict(null);
656
+ const payload = currentDraftPayload();
657
+ if (payload) await persistPayload({ ...payload, expectedRevision: remote.revision });
658
+ },
659
+ [
660
+ applyDraft,
661
+ client,
662
+ currentDraftPayload,
663
+ durableDrafts,
664
+ persistPayload,
665
+ sessionId,
666
+ targetKey,
667
+ workspaceId,
668
+ ],
669
+ );
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]);
166
678
 
167
679
  return {
168
- value,
680
+ value: identityMatches ? value : "",
169
681
  setValue: updateValue,
170
682
  send,
171
- sending,
172
- canSend: Boolean(sessionId) && !sending && (value.trim().length > 0 || hasReadyResources),
683
+ steer,
684
+ sending: identityMatches ? sending : false,
685
+ canSend:
686
+ identityMatches &&
687
+ Boolean(sessionId) &&
688
+ !sending &&
689
+ (value.trim().length > 0 || hasReadyResources),
173
690
  pause,
174
- pausing,
691
+ pausing: identityMatches ? pausing : false,
175
692
  resume,
176
- resuming,
177
- error,
178
- clearError: useCallback(() => setError(null), []),
693
+ resumeScope,
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",
701
+ applyDraft,
702
+ reloadDraft,
703
+ resolveDraftConflict,
704
+ restoredResources: identityMatches ? restoredResources : [],
705
+ removeRestoredResource,
706
+ error: identityMatches ? error : null,
707
+ clearError,
179
708
  };
180
709
  }
181
710
 
711
+ /** Events that can atomically replace or clear this subject's durable draft. */
712
+ export function isComposerDraftEvent(event: Pick<SessionEvent, "type">): boolean {
713
+ return event.type === "user.message" || event.type === "session.queue.changed";
714
+ }
715
+
182
716
  /**
183
717
  * Default text for a file-only message (attachment(s) present, no typed draft).
184
718
  * Kept non-empty so the wire contract (`text: z.string().min(1)`) and the
@@ -202,8 +736,9 @@ export function composeSendInput(
202
736
  text: string,
203
737
  clientEventId: string,
204
738
  extras: ComposerSendExtras | (() => ComposerSendExtras) | undefined,
739
+ bound: Partial<SendMessageInput> = {},
205
740
  ): SendMessageInput {
206
- return { ...resolveSendExtras(extras), text, clientEventId };
741
+ return { ...resolveSendExtras(extras), ...bound, text, clientEventId };
207
742
  }
208
743
 
209
744
  /** Submit on plain Enter; Shift+Enter inserts a newline. Exported for tests. */
@@ -221,14 +756,40 @@ export function shouldSubmitOnKey(event: {
221
756
  }
222
757
 
223
758
  /** Cmd/Ctrl+Enter steers; ordinary Enter appends to the queue. */
224
- export function composerModeForKey(event: { metaKey?: boolean; ctrlKey?: boolean }): ComposerMode {
225
- return event.metaKey || event.ctrlKey ? "steer" : "queue";
759
+ export function shouldSteerOnKey(event: { metaKey?: boolean; ctrlKey?: boolean }): boolean {
760
+ return event.metaKey === true || event.ctrlKey === true;
226
761
  }
227
762
 
228
763
  function generateClientEventId(): string {
229
- const cryptoApi = globalThis.crypto;
230
- if (cryptoApi && "randomUUID" in cryptoApi) {
231
- return cryptoApi.randomUUID();
232
- }
233
- return `ce-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
764
+ return globalThis.crypto.randomUUID();
765
+ }
766
+
767
+ function asError(cause: unknown): Error {
768
+ return cause instanceof Error ? cause : new Error(String(cause));
769
+ }
770
+
771
+ function draftPayload(draft: ComposerDraft): SaveComposerDraftRequest {
772
+ return {
773
+ expectedRevision: draft.revision,
774
+ text: draft.text,
775
+ resources: draft.resources,
776
+ tools: draft.tools,
777
+ model: draft.model,
778
+ reasoningEffort: draft.reasoningEffort,
779
+ };
780
+ }
781
+
782
+ function draftSignature(payload: SaveComposerDraftRequest): string {
783
+ const { expectedRevision: _revision, ...content } = payload;
784
+ return JSON.stringify(content);
785
+ }
786
+
787
+ function mergeResources(base: ResourceRef[], additions: ResourceRef[]): ResourceRef[] {
788
+ const seen = new Set<string>();
789
+ return [...base, ...additions].filter((resource) => {
790
+ const key = JSON.stringify(resource);
791
+ if (seen.has(key)) return false;
792
+ seen.add(key);
793
+ return true;
794
+ });
234
795
  }