@opengeni/react 0.12.0 → 0.15.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 (94) hide show
  1. package/README.md +20 -14
  2. package/dist/chunk-TOJR776I.js +2280 -0
  3. package/dist/chunk-TOJR776I.js.map +1 -0
  4. package/dist/index.d.ts +1040 -363
  5. package/dist/index.js +10938 -6281
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-BeZ3bD3t.d.ts → machines-BpdwuQcD.d.ts} +134 -14
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +23 -1
  10. package/package.json +20 -12
  11. package/src/approvals.ts +16 -4
  12. package/src/client.ts +41 -5
  13. package/src/commands/index.ts +1 -7
  14. package/src/commands/registry.ts +46 -12
  15. package/src/components/chat-composer.tsx +417 -69
  16. package/src/components/code-editor.tsx +28 -36
  17. package/src/components/command-palette.tsx +26 -5
  18. package/src/components/desktop-viewer.tsx +29 -16
  19. package/src/components/diff-view.tsx +27 -189
  20. package/src/components/enrollment-consent.tsx +28 -10
  21. package/src/components/enrollment-device-flow.tsx +8 -5
  22. package/src/components/file-browser.tsx +190 -56
  23. package/src/components/fleet-tile.tsx +13 -4
  24. package/src/components/machine-card.tsx +93 -15
  25. package/src/components/machine-dock-bar.tsx +20 -8
  26. package/src/components/machine-health-pill.tsx +68 -0
  27. package/src/components/machine-metrics.tsx +28 -19
  28. package/src/components/machine-status-pill.tsx +26 -5
  29. package/src/components/machines/health.ts +146 -0
  30. package/src/components/machines/machine-detail.tsx +220 -0
  31. package/src/components/machines/metric-history-chart.tsx +298 -0
  32. package/src/components/machines/metric-sparkline.tsx +76 -0
  33. package/src/components/machines/series.ts +113 -0
  34. package/src/components/machines-dashboard.tsx +21 -3
  35. package/src/components/markdown.tsx +39 -9
  36. package/src/components/message-timeline.tsx +633 -109
  37. package/src/components/pierre-diff.tsx +83 -15
  38. package/src/components/pierre-file.tsx +10 -9
  39. package/src/components/queue-surface.tsx +578 -0
  40. package/src/components/sandbox-files.tsx +193 -225
  41. package/src/components/sandbox-terminal.tsx +256 -88
  42. package/src/components/sandbox-workspace.tsx +957 -0
  43. package/src/components/session-status.tsx +22 -2
  44. package/src/components/workbench-changes.tsx +585 -0
  45. package/src/components/workspace-dock.tsx +213 -59
  46. package/src/hooks/internal.ts +5 -2
  47. package/src/hooks/use-available-models.ts +7 -2
  48. package/src/hooks/use-billing-usage.ts +4 -1
  49. package/src/hooks/use-codex-accounts.ts +74 -44
  50. package/src/hooks/use-composer.ts +414 -63
  51. package/src/hooks/use-environments.ts +92 -47
  52. package/src/hooks/use-file-attachments.ts +101 -55
  53. package/src/hooks/use-goal.ts +52 -16
  54. package/src/hooks/use-machine-chip.ts +134 -0
  55. package/src/hooks/use-machines.ts +34 -16
  56. package/src/hooks/use-packs.ts +24 -19
  57. package/src/hooks/use-relay-frame-stream.ts +5 -2
  58. package/src/hooks/use-rigs.ts +335 -0
  59. package/src/hooks/use-sandbox-files.ts +213 -39
  60. package/src/hooks/use-sandbox-git.ts +188 -11
  61. package/src/hooks/use-sandbox-terminal.ts +18 -14
  62. package/src/hooks/use-scheduled-tasks.ts +10 -2
  63. package/src/hooks/use-session-capabilities.ts +77 -20
  64. package/src/hooks/use-session-control.ts +50 -23
  65. package/src/hooks/use-session-events.ts +50 -21
  66. package/src/hooks/use-session-lineage.ts +128 -0
  67. package/src/hooks/use-session.ts +53 -30
  68. package/src/hooks/use-slash-commands.ts +6 -4
  69. package/src/hooks/use-turn-queue.ts +202 -147
  70. package/src/hooks/use-windowed-sections.ts +204 -0
  71. package/src/hooks/use-workspace-capture.ts +233 -0
  72. package/src/hooks/use-workspace-edit.ts +231 -0
  73. package/src/hooks/use-workspace-sessions.ts +48 -5
  74. package/src/hooks/use-workspaces.ts +18 -15
  75. package/src/index.ts +133 -23
  76. package/src/lib/format.ts +3 -3
  77. package/src/lib/xterm-renderer.ts +65 -0
  78. package/src/lib/xterm-theme.ts +224 -18
  79. package/src/machines.ts +29 -3
  80. package/src/provider.tsx +195 -6
  81. package/src/timeline/activity-rail.tsx +213 -54
  82. package/src/timeline/disclosure-context.tsx +12 -2
  83. package/src/timeline/index.ts +21 -2
  84. package/src/timeline/parsers.ts +87 -16
  85. package/src/timeline/projection.ts +412 -87
  86. package/src/timeline/shared.tsx +111 -24
  87. package/src/timeline/tool-diff.tsx +24 -20
  88. package/src/timeline/tool-renderers.tsx +98 -21
  89. package/src/timeline/turn-summary.tsx +76 -23
  90. package/src/timeline/types.ts +96 -12
  91. package/styles/index.css +22 -0
  92. package/styles/tokens.css +2 -2
  93. package/dist/chunk-5TIXRCSI.js +0 -1291
  94. package/dist/chunk-5TIXRCSI.js.map +0 -1
@@ -1,69 +1,98 @@
1
- import type { SendMessageInput } from "@opengeni/sdk";
1
+ import type {
2
+ ComposerDraft,
3
+ EffectiveControlResumeOption,
4
+ EffectiveSessionControl,
5
+ ResourceRef,
6
+ SaveComposerDraftRequest,
7
+ SendMessageInput,
8
+ SessionEvent,
9
+ } from "@opengeni/sdk";
2
10
  import { useCallback, useEffect, useRef, useState } from "react";
3
11
  import { useOpenGeni, type ClientOverride } from "../provider";
12
+ import { useSessionEventTrigger, type SessionEventFeedOptions } from "./internal";
4
13
 
5
14
  export type ComposerSendExtras = Omit<SendMessageInput, "text" | "clientEventId">;
6
15
 
7
- /**
8
- * Compose-time delivery choice. `queue` (the default) stacks the message
9
- * behind the running turn visible, editable, reorderable until claimed.
10
- * `steer` interrupts the running turn and injects the message now.
11
- */
12
- export type ComposerMode = "queue" | "steer";
13
-
14
- export type UseComposerOptions = ClientOverride & {
15
- /** Called with the accepted text after a successful send. */
16
- onSent?: ((text: string) => void) | undefined;
17
- /**
18
- * Extra message fields (resources, tools, model, reasoningEffort) merged
19
- * into every send. A function is evaluated at send time so it can read the
20
- * surrounding UI state (attachment pickers, model selectors, ...).
21
- */
22
- sendExtras?: ComposerSendExtras | (() => ComposerSendExtras) | undefined;
23
- /** Initial delivery mode. Defaults to `"queue"`. */
24
- defaultMode?: ComposerMode | undefined;
25
- };
16
+ export type UseComposerOptions = ClientOverride &
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
+ };
26
31
 
27
32
  export type ComposerState = {
28
33
  value: string;
29
34
  setValue: (value: string) => void;
30
- /** Send the draft (or an explicit text) using the current mode. */
35
+ /** Append the draft behind prompts already visible in the queue. */
31
36
  send: (text?: string) => Promise<boolean>;
37
+ /** Supersede current direction with the draft. */
38
+ steer: (text?: string) => Promise<boolean>;
32
39
  sending: boolean;
33
40
  canSend: boolean;
34
- /** Queue (default) vs steer the compose-time delivery choice. */
35
- mode: ComposerMode;
36
- setMode: (mode: ComposerMode) => void;
37
- /** Ask the agent to stop the current turn. */
38
- interrupt: (reason?: string) => Promise<void>;
39
- interrupting: boolean;
41
+ /** Pause the session without deleting its prompt queue. */
42
+ pause: (reason?: string) => Promise<void>;
43
+ pausing: boolean;
44
+ resume: (reason?: string) => Promise<void>;
45
+ resumeScope: (option: EffectiveControlResumeOption) => Promise<void>;
46
+ resuming: boolean;
47
+ draft: ComposerDraft | null;
48
+ draftRevision: number;
49
+ draftLoading: boolean;
50
+ draftSaving: boolean;
51
+ draftConflict: Error | null;
52
+ /** Apply an atomic queue Edit checkout without a second read. */
53
+ applyDraft: (draft: ComposerDraft) => void;
54
+ reloadDraft: () => Promise<void>;
55
+ resolveDraftConflict: (choice: "keep_mine" | "use_remote") => Promise<void>;
56
+ restoredResources: ResourceRef[];
57
+ removeRestoredResource: (index: number) => void;
40
58
  error: Error | null;
41
59
  clearError: () => void;
42
60
  };
43
61
 
44
62
  /**
45
- * Draft + send + interrupt state for the chat composer — the only
63
+ * Draft + send + Pause/Resume state for the chat composer — the only
46
64
  * human-to-agent input surface. The draft survives a failed send (nothing is
47
65
  * more hostile than losing a typed message); each send carries a generated
48
66
  * `clientEventId` so retries stay idempotent server-side.
49
67
  */
50
- export function useComposer(sessionId: string | null | undefined, options: UseComposerOptions = {}): ComposerState {
51
- const { client, workspaceId } = useOpenGeni(options);
52
- const defaultMode = options.defaultMode ?? "queue";
68
+ export function useComposer(
69
+ sessionId: string | null | undefined,
70
+ options: UseComposerOptions = {},
71
+ ): ComposerState {
72
+ const { client, workspaceId, registerSessionReconciler } = useOpenGeni(options);
53
73
  const [value, setValue] = useState("");
54
- const [mode, setMode] = useState<ComposerMode>(defaultMode);
55
74
  const [sending, setSending] = useState(false);
56
- const [interrupting, setInterrupting] = useState(false);
75
+ const [pausing, setPausing] = useState(false);
76
+ const [resuming, setResuming] = useState(false);
57
77
  const [error, setError] = useState<Error | null>(null);
78
+ const [draft, setDraft] = useState<ComposerDraft | null>(null);
79
+ const [draftLoading, setDraftLoading] = useState(Boolean(sessionId));
80
+ const [draftSaving, setDraftSaving] = useState(false);
81
+ const [draftConflict, setDraftConflict] = useState<Error | null>(null);
82
+ const [restoredResources, setRestoredResources] = useState<ResourceRef[]>([]);
58
83
  const pendingClientEventId = useRef<string | null>(null);
84
+ const draftRef = useRef<ComposerDraft | null>(null);
85
+ const localEditRevision = useRef(0);
86
+ const targetGeneration = useRef(0);
87
+ const lastSavedSignature = useRef<string | null>(null);
88
+ const saveChain = useRef<Promise<void>>(Promise.resolve());
59
89
  const onSent = options.onSent;
60
- // Read at send time so an in-flight send uses the mode chosen at submit.
61
- const modeRef = useRef(mode);
62
- modeRef.current = mode;
90
+ const onDraftApplied = options.onDraftApplied;
63
91
  // Read through a ref so a new extras closure (created every render by
64
92
  // callers passing inline functions) does not invalidate `send`.
65
93
  const sendExtrasRef = useRef(options.sendExtras);
66
94
  sendExtrasRef.current = options.sendExtras;
95
+ const liveExtrasVersion = JSON.stringify(resolveSendExtras(options.sendExtras));
67
96
 
68
97
  // A composer is bound to one session: switching targets must not leak the
69
98
  // previous session's draft, error, or retry idempotency key.
@@ -72,21 +101,160 @@ export function useComposer(sessionId: string | null | undefined, options: UseCo
72
101
  useEffect(() => {
73
102
  if (targetKeyRef.current !== targetKey) {
74
103
  targetKeyRef.current = targetKey;
104
+ targetGeneration.current += 1;
75
105
  pendingClientEventId.current = null;
106
+ localEditRevision.current = 0;
107
+ draftRef.current = null;
108
+ lastSavedSignature.current = null;
76
109
  setValue("");
77
110
  setError(null);
78
- setMode(defaultMode);
111
+ setDraft(null);
112
+ setDraftConflict(null);
113
+ setRestoredResources([]);
114
+ }
115
+ }, [targetKey]);
116
+
117
+ const applyDraft = useCallback(
118
+ (next: ComposerDraft): void => {
119
+ draftRef.current = next;
120
+ lastSavedSignature.current = draftSignature(draftPayload(next));
121
+ localEditRevision.current += 1;
122
+ pendingClientEventId.current = null;
123
+ setDraft(next);
124
+ setValue(next.text);
125
+ setRestoredResources(next.resources);
126
+ setDraftConflict(null);
127
+ onDraftApplied?.(next);
128
+ },
129
+ [onDraftApplied],
130
+ );
131
+
132
+ const loadDraft = useCallback(
133
+ async (replaceLocal: boolean): Promise<void> => {
134
+ if (!sessionId) return;
135
+ const generation = targetGeneration.current;
136
+ const localAtStart = localEditRevision.current;
137
+ setDraftLoading(true);
138
+ try {
139
+ 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);
149
+ }
150
+ } catch (cause) {
151
+ if (generation === targetGeneration.current) setError(asError(cause));
152
+ } finally {
153
+ if (generation === targetGeneration.current) setDraftLoading(false);
154
+ }
155
+ },
156
+ [client, onDraftApplied, sessionId, workspaceId],
157
+ );
158
+
159
+ useEffect(() => {
160
+ if (!sessionId) {
161
+ setDraftLoading(false);
162
+ return;
79
163
  }
80
- }, [targetKey, defaultMode]);
164
+ void loadDraft(false);
165
+ }, [loadDraft, sessionId]);
166
+ useEffect(() => {
167
+ if (!sessionId) return;
168
+ return registerSessionReconciler(sessionId, "composer", async () => await loadDraft(false));
169
+ }, [loadDraft, registerSessionReconciler, sessionId]);
170
+ useSessionEventTrigger(
171
+ client,
172
+ workspaceId,
173
+ sessionId,
174
+ isComposerDraftEvent,
175
+ () => void loadDraft(false),
176
+ {
177
+ enabled: Boolean(sessionId),
178
+ ...(options.events !== undefined ? { events: options.events } : {}),
179
+ },
180
+ );
181
+
182
+ const currentDraftPayload = useCallback((): SaveComposerDraftRequest | null => {
183
+ const base = draftRef.current;
184
+ if (!base) return null;
185
+ const extras = resolveSendExtras(sendExtrasRef.current);
186
+ return {
187
+ expectedRevision: base.revision,
188
+ text: value,
189
+ resources: mergeResources(restoredResources, extras.resources ?? []),
190
+ tools: extras.tools ?? base.tools,
191
+ model: extras.model ?? base.model,
192
+ reasoningEffort: extras.reasoningEffort ?? base.reasoningEffort,
193
+ };
194
+ }, [restoredResources, value]);
195
+
196
+ const persistPayload = useCallback(
197
+ async (payload: SaveComposerDraftRequest): Promise<boolean> => {
198
+ if (!sessionId) return false;
199
+ let success = false;
200
+ const run = async () => {
201
+ const current = draftRef.current;
202
+ if (!current) return;
203
+ const request = { ...payload, expectedRevision: current.revision };
204
+ const signature = draftSignature(request);
205
+ if (signature === lastSavedSignature.current) {
206
+ success = true;
207
+ return;
208
+ }
209
+ setDraftSaving(true);
210
+ try {
211
+ const saved = await client.saveComposerDraft(workspaceId, sessionId, request);
212
+ draftRef.current = saved;
213
+ setDraft(saved);
214
+ lastSavedSignature.current = signature;
215
+ setDraftConflict(null);
216
+ success = true;
217
+ } catch (cause) {
218
+ const problem = asError(cause);
219
+ setDraftConflict(problem);
220
+ setError(problem);
221
+ } finally {
222
+ setDraftSaving(false);
223
+ }
224
+ };
225
+ saveChain.current = saveChain.current.then(run, run);
226
+ await saveChain.current;
227
+ return success;
228
+ },
229
+ [client, sessionId, workspaceId],
230
+ );
81
231
 
82
- const send = useCallback(
83
- async (explicit?: string): Promise<boolean> => {
232
+ // Private durable autosave. A newer local edit is never replaced by an older
233
+ // response; saves serialize and each reads the latest acknowledged revision.
234
+ useEffect(() => {
235
+ if (!sessionId || draftLoading || sending || !draftRef.current || draftConflict) return;
236
+ const payload = currentDraftPayload();
237
+ if (!payload || draftSignature(payload) === lastSavedSignature.current) return;
238
+ const timer = window.setTimeout(() => void persistPayload(payload), 500);
239
+ return () => window.clearTimeout(timer);
240
+ }, [
241
+ currentDraftPayload,
242
+ draftConflict,
243
+ draftLoading,
244
+ liveExtrasVersion,
245
+ persistPayload,
246
+ sending,
247
+ sessionId,
248
+ ]);
249
+
250
+ const dispatch = useCallback(
251
+ async (delivery: "send" | "steer", explicit?: string): Promise<boolean> => {
84
252
  const draftAtSend = value;
85
253
  const text = (explicit ?? draftAtSend).trim();
86
254
  // Resolve the extras once: a file-only message (empty text + ≥1 ready
87
255
  // resource) is legitimate, so we must not bail on empty text alone.
88
256
  const extras = resolveSendExtras(sendExtrasRef.current);
89
- const hasResources = (extras.resources?.length ?? 0) > 0;
257
+ const hasResources = restoredResources.length > 0 || (extras.resources?.length ?? 0) > 0;
90
258
  if ((!text && !hasResources) || !sessionId || sending) {
91
259
  return false;
92
260
  }
@@ -96,17 +264,41 @@ export function useComposer(sessionId: string | null | undefined, options: UseCo
96
264
  setSending(true);
97
265
  setError(null);
98
266
  try {
267
+ const payload = currentDraftPayload();
268
+ if (payload && !(await persistPayload(payload))) return false;
99
269
  // The wire contract requires non-empty text (z.string().min(1)) and the
100
270
  // worker rejects whitespace-only text; a file-only message therefore
101
271
  // carries a minimal default so the attachments still get delivered.
102
272
  const sendText = text || FILE_ONLY_MESSAGE_TEXT;
103
- const input = composeSendInput(sendText, pendingClientEventId.current, extras);
104
- if (modeRef.current === "steer") {
273
+ const input = composeSendInput(sendText, pendingClientEventId.current, extras, {
274
+ ...(options.effectiveControl?.controlEtag
275
+ ? { controlEtag: options.effectiveControl.controlEtag }
276
+ : {}),
277
+ ...(draftRef.current ? { expectedDraftRevision: draftRef.current.revision } : {}),
278
+ resources: mergeResources(restoredResources, extras.resources ?? []),
279
+ });
280
+ if (delivery === "steer") {
105
281
  await client.steerMessage(workspaceId, sessionId, input);
106
282
  } else {
107
283
  await client.sendMessage(workspaceId, sessionId, input);
108
284
  }
109
285
  pendingClientEventId.current = null;
286
+ const previousDraft = draftRef.current;
287
+ if (previousDraft) {
288
+ const cleared = {
289
+ ...previousDraft,
290
+ revision: 0,
291
+ text: "",
292
+ resources: [],
293
+ sourceTurnId: null,
294
+ sourceTurnVersion: null,
295
+ updatedAt: null,
296
+ };
297
+ draftRef.current = cleared;
298
+ setDraft(cleared);
299
+ setRestoredResources([]);
300
+ lastSavedSignature.current = draftSignature(draftPayload(cleared));
301
+ }
110
302
  if (explicit === undefined) {
111
303
  // Clear only the draft that was sent: edits made while the request
112
304
  // was in flight were never delivered and must survive.
@@ -121,54 +313,175 @@ export function useComposer(sessionId: string | null | undefined, options: UseCo
121
313
  setSending(false);
122
314
  }
123
315
  },
124
- [client, workspaceId, sessionId, value, sending, onSent],
316
+ [
317
+ client,
318
+ currentDraftPayload,
319
+ onSent,
320
+ options.effectiveControl?.controlEtag,
321
+ persistPayload,
322
+ restoredResources,
323
+ sending,
324
+ sessionId,
325
+ value,
326
+ workspaceId,
327
+ ],
125
328
  );
126
329
 
330
+ const send = useCallback(async (text?: string) => await dispatch("send", text), [dispatch]);
331
+ const steer = useCallback(async (text?: string) => await dispatch("steer", text), [dispatch]);
332
+
127
333
  // A send is possible with non-empty text OR with ≥1 attached resource (a
128
334
  // file-only message). Resources ride in `sendExtras`, so we resolve them here
129
335
  // — keeping useComposer attachment-agnostic while still lighting up the send
130
336
  // affordance the moment a file is ready. ChatComposer additionally gates this
131
337
  // on its `attachments.uploading` flag so a message never departs mid-upload.
132
- const hasReadyResources = (resolveSendExtras(sendExtrasRef.current).resources?.length ?? 0) > 0;
338
+ const hasReadyResources =
339
+ restoredResources.length > 0 ||
340
+ (resolveSendExtras(sendExtrasRef.current).resources?.length ?? 0) > 0;
133
341
 
134
- const interrupt = useCallback(
342
+ const pause = useCallback(
135
343
  async (reason?: string): Promise<void> => {
136
- if (!sessionId || interrupting) {
344
+ if (!sessionId || pausing) {
137
345
  return;
138
346
  }
139
- setInterrupting(true);
347
+ setPausing(true);
348
+ setError(null);
349
+ try {
350
+ await client.pauseSession(workspaceId, sessionId, {
351
+ ...(reason !== undefined ? { reason } : {}),
352
+ ...(options.effectiveControl?.controlEtag
353
+ ? { expectedControlEtag: options.effectiveControl.controlEtag }
354
+ : {}),
355
+ });
356
+ } catch (cause) {
357
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
358
+ } finally {
359
+ setPausing(false);
360
+ }
361
+ },
362
+ [client, workspaceId, sessionId, pausing, options.effectiveControl?.controlEtag],
363
+ );
364
+
365
+ const resume = useCallback(
366
+ async (reason?: string): Promise<void> => {
367
+ if (!sessionId || resuming) return;
368
+ setResuming(true);
140
369
  setError(null);
141
370
  try {
142
- await client.interrupt(workspaceId, sessionId, reason !== undefined ? { reason } : {});
371
+ await client.resumeSession(workspaceId, sessionId, {
372
+ ...(reason !== undefined ? { reason } : {}),
373
+ ...(options.effectiveControl?.controlEtag
374
+ ? { expectedControlEtag: options.effectiveControl.controlEtag }
375
+ : {}),
376
+ });
143
377
  } catch (cause) {
144
378
  setError(cause instanceof Error ? cause : new Error(String(cause)));
145
379
  } finally {
146
- setInterrupting(false);
380
+ setResuming(false);
147
381
  }
148
382
  },
149
- [client, workspaceId, sessionId, interrupting],
383
+ [client, workspaceId, sessionId, resuming, options.effectiveControl?.controlEtag],
384
+ );
385
+
386
+ const resumeScope = useCallback(
387
+ async (option: EffectiveControlResumeOption): Promise<void> => {
388
+ if (!sessionId || resuming) return;
389
+ setResuming(true);
390
+ setError(null);
391
+ try {
392
+ if (option.scope === "workspace") {
393
+ const workspaceBlocker = options.effectiveControl?.blockers.find(
394
+ (blocker) => blocker.kind === "workspace",
395
+ );
396
+ await client.setWorkspaceInferenceState(workspaceId, {
397
+ action: "resume",
398
+ clientEventId: generateClientEventId(),
399
+ ...(workspaceBlocker ? { expectedRevision: workspaceBlocker.revision } : {}),
400
+ });
401
+ } else if (option.scope === "session" && option.targetId) {
402
+ const target = await client.getQueue(workspaceId, option.targetId);
403
+ await client.resumeSession(workspaceId, option.targetId, {
404
+ expectedControlEtag: target.effectiveControl.controlEtag,
405
+ });
406
+ } else {
407
+ await client.resumeSession(workspaceId, sessionId, {
408
+ ...(options.effectiveControl?.controlEtag
409
+ ? { expectedControlEtag: options.effectiveControl.controlEtag }
410
+ : {}),
411
+ });
412
+ }
413
+ } catch (cause) {
414
+ setError(asError(cause));
415
+ } finally {
416
+ setResuming(false);
417
+ }
418
+ },
419
+ [client, options.effectiveControl, resuming, sessionId, workspaceId],
150
420
  );
151
421
 
152
422
  const updateValue = useCallback((next: string) => {
153
423
  pendingClientEventId.current = null;
424
+ localEditRevision.current += 1;
154
425
  setValue(next);
155
426
  }, []);
156
427
 
428
+ const removeRestoredResource = useCallback((index: number) => {
429
+ localEditRevision.current += 1;
430
+ setRestoredResources((current) => current.filter((_, candidate) => candidate !== index));
431
+ }, []);
432
+
433
+ const resolveDraftConflict = useCallback(
434
+ async (choice: "keep_mine" | "use_remote"): Promise<void> => {
435
+ if (!sessionId) return;
436
+ const remote = await client.getComposerDraft(workspaceId, sessionId);
437
+ if (choice === "use_remote") {
438
+ applyDraft(remote);
439
+ return;
440
+ }
441
+ draftRef.current = remote;
442
+ setDraft(remote);
443
+ setDraftConflict(null);
444
+ const payload = currentDraftPayload();
445
+ if (payload) await persistPayload({ ...payload, expectedRevision: remote.revision });
446
+ },
447
+ [applyDraft, client, currentDraftPayload, persistPayload, sessionId, workspaceId],
448
+ );
449
+
157
450
  return {
158
451
  value,
159
452
  setValue: updateValue,
160
453
  send,
454
+ steer,
161
455
  sending,
162
456
  canSend: Boolean(sessionId) && !sending && (value.trim().length > 0 || hasReadyResources),
163
- mode,
164
- setMode,
165
- interrupt,
166
- interrupting,
457
+ pause,
458
+ pausing,
459
+ resume,
460
+ resumeScope,
461
+ resuming,
462
+ draft,
463
+ draftRevision: draft?.revision ?? 0,
464
+ draftLoading,
465
+ draftSaving,
466
+ draftConflict,
467
+ applyDraft,
468
+ reloadDraft: useCallback(async () => await loadDraft(true), [loadDraft]),
469
+ resolveDraftConflict,
470
+ restoredResources,
471
+ removeRestoredResource,
167
472
  error,
168
- clearError: useCallback(() => setError(null), []),
473
+ clearError: useCallback(() => {
474
+ setError(null);
475
+ setDraftConflict(null);
476
+ }, []),
169
477
  };
170
478
  }
171
479
 
480
+ /** Events that can atomically replace or clear this subject's durable draft. */
481
+ export function isComposerDraftEvent(event: Pick<SessionEvent, "type">): boolean {
482
+ return event.type === "user.message" || event.type === "session.queue.changed";
483
+ }
484
+
172
485
  /**
173
486
  * Default text for a file-only message (attachment(s) present, no typed draft).
174
487
  * Kept non-empty so the wire contract (`text: z.string().min(1)`) and the
@@ -192,22 +505,60 @@ export function composeSendInput(
192
505
  text: string,
193
506
  clientEventId: string,
194
507
  extras: ComposerSendExtras | (() => ComposerSendExtras) | undefined,
508
+ bound: Partial<SendMessageInput> = {},
195
509
  ): SendMessageInput {
196
- return { ...resolveSendExtras(extras), text, clientEventId };
510
+ return { ...resolveSendExtras(extras), ...bound, text, clientEventId };
197
511
  }
198
512
 
199
513
  /** Submit on plain Enter; Shift+Enter inserts a newline. Exported for tests. */
200
- export function shouldSubmitOnKey(event: { key: string; shiftKey: boolean; nativeEvent?: { isComposing?: boolean } }): boolean {
514
+ export function shouldSubmitOnKey(event: {
515
+ key: string;
516
+ shiftKey: boolean;
517
+ metaKey?: boolean;
518
+ ctrlKey?: boolean;
519
+ nativeEvent?: { isComposing?: boolean };
520
+ }): boolean {
201
521
  if (event.key !== "Enter" || event.shiftKey) {
202
522
  return false;
203
523
  }
204
524
  return event.nativeEvent?.isComposing !== true;
205
525
  }
206
526
 
527
+ /** Cmd/Ctrl+Enter steers; ordinary Enter appends to the queue. */
528
+ export function shouldSteerOnKey(event: { metaKey?: boolean; ctrlKey?: boolean }): boolean {
529
+ return event.metaKey === true || event.ctrlKey === true;
530
+ }
531
+
207
532
  function generateClientEventId(): string {
208
- const cryptoApi = globalThis.crypto;
209
- if (cryptoApi && "randomUUID" in cryptoApi) {
210
- return cryptoApi.randomUUID();
211
- }
212
- return `ce-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
533
+ return globalThis.crypto.randomUUID();
534
+ }
535
+
536
+ function asError(cause: unknown): Error {
537
+ return cause instanceof Error ? cause : new Error(String(cause));
538
+ }
539
+
540
+ function draftPayload(draft: ComposerDraft): SaveComposerDraftRequest {
541
+ return {
542
+ expectedRevision: draft.revision,
543
+ text: draft.text,
544
+ resources: draft.resources,
545
+ tools: draft.tools,
546
+ model: draft.model,
547
+ reasoningEffort: draft.reasoningEffort,
548
+ };
549
+ }
550
+
551
+ function draftSignature(payload: SaveComposerDraftRequest): string {
552
+ const { expectedRevision: _revision, ...content } = payload;
553
+ return JSON.stringify(content);
554
+ }
555
+
556
+ function mergeResources(base: ResourceRef[], additions: ResourceRef[]): ResourceRef[] {
557
+ const seen = new Set<string>();
558
+ return [...base, ...additions].filter((resource) => {
559
+ const key = JSON.stringify(resource);
560
+ if (seen.has(key)) return false;
561
+ seen.add(key);
562
+ return true;
563
+ });
213
564
  }