@opengeni/react 0.13.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 (41) hide show
  1. package/README.md +19 -13
  2. package/dist/chunk-TOJR776I.js +2280 -0
  3. package/dist/chunk-TOJR776I.js.map +1 -0
  4. package/dist/index.d.ts +314 -255
  5. package/dist/index.js +5862 -4404
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-CnlMb7E-.d.ts → machines-BpdwuQcD.d.ts} +130 -10
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +23 -1
  10. package/package.json +5 -2
  11. package/src/client.ts +10 -1
  12. package/src/components/chat-composer.tsx +309 -57
  13. package/src/components/machine-card.tsx +81 -15
  14. package/src/components/machine-health-pill.tsx +68 -0
  15. package/src/components/machine-metrics.tsx +10 -24
  16. package/src/components/machines/health.ts +146 -0
  17. package/src/components/machines/machine-detail.tsx +220 -0
  18. package/src/components/machines/metric-history-chart.tsx +298 -0
  19. package/src/components/machines/metric-sparkline.tsx +76 -0
  20. package/src/components/machines/series.ts +113 -0
  21. package/src/components/machines-dashboard.tsx +13 -1
  22. package/src/components/queue-surface.tsx +578 -0
  23. package/src/components/sandbox-files.tsx +94 -9
  24. package/src/components/sandbox-workspace.tsx +186 -52
  25. package/src/components/session-status.tsx +0 -6
  26. package/src/components/workbench-changes.tsx +64 -20
  27. package/src/components/workspace-dock.tsx +146 -55
  28. package/src/hooks/use-composer.ts +369 -39
  29. package/src/hooks/use-session-control.ts +6 -7
  30. package/src/hooks/use-session-events.ts +3 -2
  31. package/src/hooks/use-session-lineage.ts +15 -6
  32. package/src/hooks/use-session.ts +10 -2
  33. package/src/hooks/use-turn-queue.ts +175 -47
  34. package/src/index.ts +13 -7
  35. package/src/machines.ts +16 -0
  36. package/src/provider.tsx +192 -5
  37. package/src/timeline/parsers.ts +43 -6
  38. package/src/timeline/projection.ts +24 -2
  39. package/styles/index.css +22 -0
  40. package/dist/chunk-NFYVQWIB.js +0 -1377
  41. package/dist/chunk-NFYVQWIB.js.map +0 -1
@@ -1,38 +1,60 @@
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
- * 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 = 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
+ };
23
31
 
24
32
  export type ComposerState = {
25
33
  value: string;
26
34
  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>;
35
+ /** Append the draft behind prompts already visible in the queue. */
36
+ send: (text?: string) => Promise<boolean>;
37
+ /** Supersede current direction with the draft. */
38
+ steer: (text?: string) => Promise<boolean>;
29
39
  sending: boolean;
30
40
  canSend: boolean;
31
41
  /** Pause the session without deleting its prompt queue. */
32
42
  pause: (reason?: string) => Promise<void>;
33
43
  pausing: boolean;
34
44
  resume: (reason?: string) => Promise<void>;
45
+ resumeScope: (option: EffectiveControlResumeOption) => Promise<void>;
35
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;
36
58
  error: Error | null;
37
59
  clearError: () => void;
38
60
  };
@@ -47,18 +69,30 @@ export function useComposer(
47
69
  sessionId: string | null | undefined,
48
70
  options: UseComposerOptions = {},
49
71
  ): ComposerState {
50
- const { client, workspaceId } = useOpenGeni(options);
72
+ const { client, workspaceId, registerSessionReconciler } = useOpenGeni(options);
51
73
  const [value, setValue] = useState("");
52
74
  const [sending, setSending] = useState(false);
53
75
  const [pausing, setPausing] = useState(false);
54
76
  const [resuming, setResuming] = useState(false);
55
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[]>([]);
56
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());
57
89
  const onSent = options.onSent;
90
+ const onDraftApplied = options.onDraftApplied;
58
91
  // Read through a ref so a new extras closure (created every render by
59
92
  // callers passing inline functions) does not invalidate `send`.
60
93
  const sendExtrasRef = useRef(options.sendExtras);
61
94
  sendExtrasRef.current = options.sendExtras;
95
+ const liveExtrasVersion = JSON.stringify(resolveSendExtras(options.sendExtras));
62
96
 
63
97
  // A composer is bound to one session: switching targets must not leak the
64
98
  // previous session's draft, error, or retry idempotency key.
@@ -67,20 +101,160 @@ export function useComposer(
67
101
  useEffect(() => {
68
102
  if (targetKeyRef.current !== targetKey) {
69
103
  targetKeyRef.current = targetKey;
104
+ targetGeneration.current += 1;
70
105
  pendingClientEventId.current = null;
106
+ localEditRevision.current = 0;
107
+ draftRef.current = null;
108
+ lastSavedSignature.current = null;
71
109
  setValue("");
72
110
  setError(null);
111
+ setDraft(null);
112
+ setDraftConflict(null);
113
+ setRestoredResources([]);
73
114
  }
74
115
  }, [targetKey]);
75
116
 
76
- const send = useCallback(
77
- async (explicit?: string, delivery: ComposerMode = "queue"): Promise<boolean> => {
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;
163
+ }
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
+ );
231
+
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> => {
78
252
  const draftAtSend = value;
79
253
  const text = (explicit ?? draftAtSend).trim();
80
254
  // Resolve the extras once: a file-only message (empty text + ≥1 ready
81
255
  // resource) is legitimate, so we must not bail on empty text alone.
82
256
  const extras = resolveSendExtras(sendExtrasRef.current);
83
- const hasResources = (extras.resources?.length ?? 0) > 0;
257
+ const hasResources = restoredResources.length > 0 || (extras.resources?.length ?? 0) > 0;
84
258
  if ((!text && !hasResources) || !sessionId || sending) {
85
259
  return false;
86
260
  }
@@ -90,17 +264,41 @@ export function useComposer(
90
264
  setSending(true);
91
265
  setError(null);
92
266
  try {
267
+ const payload = currentDraftPayload();
268
+ if (payload && !(await persistPayload(payload))) return false;
93
269
  // The wire contract requires non-empty text (z.string().min(1)) and the
94
270
  // worker rejects whitespace-only text; a file-only message therefore
95
271
  // carries a minimal default so the attachments still get delivered.
96
272
  const sendText = text || FILE_ONLY_MESSAGE_TEXT;
97
- const input = composeSendInput(sendText, pendingClientEventId.current, extras);
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
+ });
98
280
  if (delivery === "steer") {
99
281
  await client.steerMessage(workspaceId, sessionId, input);
100
282
  } else {
101
283
  await client.sendMessage(workspaceId, sessionId, input);
102
284
  }
103
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
+ }
104
302
  if (explicit === undefined) {
105
303
  // Clear only the draft that was sent: edits made while the request
106
304
  // was in flight were never delivered and must survive.
@@ -115,15 +313,31 @@ export function useComposer(
115
313
  setSending(false);
116
314
  }
117
315
  },
118
- [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
+ ],
119
328
  );
120
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
+
121
333
  // A send is possible with non-empty text OR with ≥1 attached resource (a
122
334
  // file-only message). Resources ride in `sendExtras`, so we resolve them here
123
335
  // — keeping useComposer attachment-agnostic while still lighting up the send
124
336
  // affordance the moment a file is ready. ChatComposer additionally gates this
125
337
  // on its `attachments.uploading` flag so a message never departs mid-upload.
126
- const hasReadyResources = (resolveSendExtras(sendExtrasRef.current).resources?.length ?? 0) > 0;
338
+ const hasReadyResources =
339
+ restoredResources.length > 0 ||
340
+ (resolveSendExtras(sendExtrasRef.current).resources?.length ?? 0) > 0;
127
341
 
128
342
  const pause = useCallback(
129
343
  async (reason?: string): Promise<void> => {
@@ -133,14 +347,19 @@ export function useComposer(
133
347
  setPausing(true);
134
348
  setError(null);
135
349
  try {
136
- await client.pauseSession(workspaceId, sessionId, reason !== undefined ? { reason } : {});
350
+ await client.pauseSession(workspaceId, sessionId, {
351
+ ...(reason !== undefined ? { reason } : {}),
352
+ ...(options.effectiveControl?.controlEtag
353
+ ? { expectedControlEtag: options.effectiveControl.controlEtag }
354
+ : {}),
355
+ });
137
356
  } catch (cause) {
138
357
  setError(cause instanceof Error ? cause : new Error(String(cause)));
139
358
  } finally {
140
359
  setPausing(false);
141
360
  }
142
361
  },
143
- [client, workspaceId, sessionId, pausing],
362
+ [client, workspaceId, sessionId, pausing, options.effectiveControl?.controlEtag],
144
363
  );
145
364
 
146
365
  const resume = useCallback(
@@ -149,36 +368,120 @@ export function useComposer(
149
368
  setResuming(true);
150
369
  setError(null);
151
370
  try {
152
- await client.resumeSession(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
+ });
153
377
  } catch (cause) {
154
378
  setError(cause instanceof Error ? cause : new Error(String(cause)));
155
379
  } finally {
156
380
  setResuming(false);
157
381
  }
158
382
  },
159
- [client, workspaceId, sessionId, resuming],
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],
160
420
  );
161
421
 
162
422
  const updateValue = useCallback((next: string) => {
163
423
  pendingClientEventId.current = null;
424
+ localEditRevision.current += 1;
164
425
  setValue(next);
165
426
  }, []);
166
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
+
167
450
  return {
168
451
  value,
169
452
  setValue: updateValue,
170
453
  send,
454
+ steer,
171
455
  sending,
172
456
  canSend: Boolean(sessionId) && !sending && (value.trim().length > 0 || hasReadyResources),
173
457
  pause,
174
458
  pausing,
175
459
  resume,
460
+ resumeScope,
176
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,
177
472
  error,
178
- clearError: useCallback(() => setError(null), []),
473
+ clearError: useCallback(() => {
474
+ setError(null);
475
+ setDraftConflict(null);
476
+ }, []),
179
477
  };
180
478
  }
181
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
+
182
485
  /**
183
486
  * Default text for a file-only message (attachment(s) present, no typed draft).
184
487
  * Kept non-empty so the wire contract (`text: z.string().min(1)`) and the
@@ -202,8 +505,9 @@ export function composeSendInput(
202
505
  text: string,
203
506
  clientEventId: string,
204
507
  extras: ComposerSendExtras | (() => ComposerSendExtras) | undefined,
508
+ bound: Partial<SendMessageInput> = {},
205
509
  ): SendMessageInput {
206
- return { ...resolveSendExtras(extras), text, clientEventId };
510
+ return { ...resolveSendExtras(extras), ...bound, text, clientEventId };
207
511
  }
208
512
 
209
513
  /** Submit on plain Enter; Shift+Enter inserts a newline. Exported for tests. */
@@ -221,14 +525,40 @@ export function shouldSubmitOnKey(event: {
221
525
  }
222
526
 
223
527
  /** 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";
528
+ export function shouldSteerOnKey(event: { metaKey?: boolean; ctrlKey?: boolean }): boolean {
529
+ return event.metaKey === true || event.ctrlKey === true;
226
530
  }
227
531
 
228
532
  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)}`;
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
+ });
234
564
  }
@@ -1,4 +1,4 @@
1
- import type { SessionEvent } from "@opengeni/sdk";
1
+ import type { SessionControlResponse, SessionEvent } from "@opengeni/sdk";
2
2
  import { useCallback } from "react";
3
3
  import { useOpenGeni, type ClientOverride } from "../provider";
4
4
  import { useMutationRunner } from "./internal";
@@ -6,8 +6,8 @@ import { useMutationRunner } from "./internal";
6
6
  export type UseSessionControlOptions = ClientOverride;
7
7
 
8
8
  export type UseSessionControlResult = {
9
- pause: (reason?: string) => Promise<SessionEvent | null>;
10
- resume: (reason?: string) => Promise<SessionEvent | null>;
9
+ pause: (reason?: string) => Promise<SessionControlResponse | null>;
10
+ resume: (reason?: string) => Promise<SessionControlResponse | null>;
11
11
  controlling: boolean;
12
12
  /** Approve a pending `requires_action` approval. */
13
13
  approve: (approvalId: string, message?: string) => Promise<SessionEvent | null>;
@@ -43,7 +43,7 @@ export function useSessionControl(
43
43
  } = useMutationRunner();
44
44
 
45
45
  const pause = useCallback(
46
- async (reason?: string): Promise<SessionEvent | null> => {
46
+ async (reason?: string): Promise<SessionControlResponse | null> => {
47
47
  if (!sessionId) {
48
48
  return null;
49
49
  }
@@ -55,12 +55,11 @@ export function useSessionControl(
55
55
  );
56
56
 
57
57
  const resume = useCallback(
58
- async (reason?: string): Promise<SessionEvent | null> => {
58
+ async (reason?: string): Promise<SessionControlResponse | null> => {
59
59
  if (!sessionId) return null;
60
- const result = await runControl(() =>
60
+ return await runControl(() =>
61
61
  client.resumeSession(workspaceId, sessionId, reason !== undefined ? { reason } : {}),
62
62
  );
63
- return result?.event ?? null;
64
63
  },
65
64
  [client, workspaceId, sessionId, runControl],
66
65
  );
@@ -58,7 +58,7 @@ export function useSessionEvents(
58
58
  sessionId: string | null | undefined,
59
59
  options: UseSessionEventsOptions = {},
60
60
  ): UseSessionEventsResult {
61
- const { client, workspaceId } = useOpenGeni(options);
61
+ const { client, workspaceId, reconcileSession } = useOpenGeni(options);
62
62
  const enabled = options.enabled ?? true;
63
63
  const after = options.after ?? 0;
64
64
  const replay = options.replay ?? "windowed";
@@ -160,6 +160,7 @@ export function useSessionEvents(
160
160
  const stream = client.streamEvents(workspaceId, sessionId, {
161
161
  after: lastSequenceRef.current,
162
162
  signal: controller.signal,
163
+ beforeLive: async () => await reconcileSession(sessionId),
163
164
  onStateChange: (state) => {
164
165
  if (!controller.signal.aborted) {
165
166
  setConnectionState(state);
@@ -189,7 +190,7 @@ export function useSessionEvents(
189
190
  clearTimeout(flushTimer);
190
191
  }
191
192
  };
192
- }, [client, workspaceId, sessionId, after, enabled, fullReplay, streamKey]);
193
+ }, [client, workspaceId, sessionId, after, enabled, fullReplay, streamKey, reconcileSession]);
193
194
 
194
195
  const loadOlder = useCallback(async (): Promise<boolean> => {
195
196
  if (!sessionId || fullReplay || loadingOlderRef.current || !hasOlderRef.current) {
@@ -57,7 +57,8 @@ export function useSessionLineage(
57
57
  sessionId: string | null | undefined,
58
58
  options: UseSessionLineageOptions = {},
59
59
  ): UseSessionLineageResult {
60
- const { client, workspaceId } = useOpenGeni(options);
60
+ const { client, workspaceId, workspaceControlEvent, registerSessionReconciler } =
61
+ useOpenGeni(options);
61
62
  const enabled = (options.enabled ?? true) && Boolean(sessionId);
62
63
  const load = useCallback(
63
64
  async () =>
@@ -67,7 +68,15 @@ export function useSessionLineage(
67
68
  [client, workspaceId, sessionId],
68
69
  );
69
70
  const state = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled });
70
- const refreshSoon = useDebouncedCallback(() => void state.refresh(), 150);
71
+ const refresh = state.refresh;
72
+ useEffect(() => {
73
+ if (enabled && workspaceControlEvent) void refresh();
74
+ }, [enabled, refresh, workspaceControlEvent]);
75
+ useEffect(() => {
76
+ if (!sessionId || !enabled) return;
77
+ return registerSessionReconciler(sessionId, "lineage", refresh);
78
+ }, [enabled, refresh, registerSessionReconciler, sessionId]);
79
+ const refreshSoon = useDebouncedCallback(() => void refresh(), 150);
71
80
  const delayedChildRefreshRef = useRef<ReturnType<typeof setTimeout> | null>(null);
72
81
  // Clear a pending delayed refresh on session/workspace SWITCH (not just
73
82
  // unmount): otherwise a timer scheduled for the previous session can fire
@@ -81,15 +90,15 @@ export function useSessionLineage(
81
90
  };
82
91
  }, [sessionId, workspaceId]);
83
92
  const refreshAfterChildCreate = useCallback(() => {
84
- void state.refresh();
93
+ void refresh();
85
94
  if (delayedChildRefreshRef.current !== null) {
86
95
  clearTimeout(delayedChildRefreshRef.current);
87
96
  }
88
97
  delayedChildRefreshRef.current = setTimeout(() => {
89
98
  delayedChildRefreshRef.current = null;
90
- void state.refresh();
99
+ void refresh();
91
100
  }, 2500);
92
- }, [state]);
101
+ }, [refresh]);
93
102
  useSessionEventTrigger(
94
103
  client,
95
104
  workspaceId,
@@ -114,6 +123,6 @@ export function useSessionLineage(
114
123
  lineage: state.data,
115
124
  loading: state.loading,
116
125
  error: state.error,
117
- refresh: state.refresh,
126
+ refresh,
118
127
  };
119
128
  }