@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.
- package/README.md +19 -13
- package/dist/chunk-TOJR776I.js +2280 -0
- package/dist/chunk-TOJR776I.js.map +1 -0
- package/dist/index.d.ts +314 -255
- package/dist/index.js +5862 -4404
- package/dist/index.js.map +1 -1
- package/dist/{machines-CnlMb7E-.d.ts → machines-BpdwuQcD.d.ts} +130 -10
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +23 -1
- package/package.json +5 -2
- package/src/client.ts +10 -1
- package/src/components/chat-composer.tsx +309 -57
- package/src/components/machine-card.tsx +81 -15
- package/src/components/machine-health-pill.tsx +68 -0
- package/src/components/machine-metrics.tsx +10 -24
- package/src/components/machines/health.ts +146 -0
- package/src/components/machines/machine-detail.tsx +220 -0
- package/src/components/machines/metric-history-chart.tsx +298 -0
- package/src/components/machines/metric-sparkline.tsx +76 -0
- package/src/components/machines/series.ts +113 -0
- package/src/components/machines-dashboard.tsx +13 -1
- package/src/components/queue-surface.tsx +578 -0
- package/src/components/sandbox-files.tsx +94 -9
- package/src/components/sandbox-workspace.tsx +186 -52
- package/src/components/session-status.tsx +0 -6
- package/src/components/workbench-changes.tsx +64 -20
- package/src/components/workspace-dock.tsx +146 -55
- package/src/hooks/use-composer.ts +369 -39
- package/src/hooks/use-session-control.ts +6 -7
- package/src/hooks/use-session-events.ts +3 -2
- package/src/hooks/use-session-lineage.ts +15 -6
- package/src/hooks/use-session.ts +10 -2
- package/src/hooks/use-turn-queue.ts +175 -47
- package/src/index.ts +13 -7
- package/src/machines.ts +16 -0
- package/src/provider.tsx +192 -5
- package/src/timeline/parsers.ts +43 -6
- package/src/timeline/projection.ts +24 -2
- package/styles/index.css +22 -0
- package/dist/chunk-NFYVQWIB.js +0 -1377
- package/dist/chunk-NFYVQWIB.js.map +0 -1
|
@@ -1,38 +1,60 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
/**
|
|
28
|
-
send: (text?: string
|
|
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
|
|
77
|
-
|
|
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
|
-
[
|
|
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 =
|
|
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,
|
|
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,
|
|
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(() =>
|
|
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
|
|
225
|
-
return event.metaKey || event.ctrlKey
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
return
|
|
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<
|
|
10
|
-
resume: (reason?: string) => Promise<
|
|
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<
|
|
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<
|
|
58
|
+
async (reason?: string): Promise<SessionControlResponse | null> => {
|
|
59
59
|
if (!sessionId) return null;
|
|
60
|
-
|
|
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 } =
|
|
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
|
|
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
|
|
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
|
|
99
|
+
void refresh();
|
|
91
100
|
}, 2500);
|
|
92
|
-
}, [
|
|
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
|
|
126
|
+
refresh,
|
|
118
127
|
};
|
|
119
128
|
}
|