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