@opengeni/react 0.25.0 → 0.26.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-AVPU5PMC.js → chunk-F4CFWKVI.js} +332 -64
- package/dist/chunk-F4CFWKVI.js.map +1 -0
- package/dist/chunk-GQN2QIR2.js +3767 -0
- package/dist/chunk-GQN2QIR2.js.map +1 -0
- package/dist/{chunk-I3BJZIG5.js → chunk-HIWPQYWI.js} +2 -120
- package/dist/chunk-HIWPQYWI.js.map +1 -0
- package/dist/chunk-M7X4JZOD.js +121 -0
- package/dist/chunk-M7X4JZOD.js.map +1 -0
- package/dist/{chunk-SHOFILHJ.js → chunk-MP6237JB.js} +570 -984
- package/dist/chunk-MP6237JB.js.map +1 -0
- package/dist/chunk-OZDLELJQ.js +937 -0
- package/dist/chunk-OZDLELJQ.js.map +1 -0
- package/dist/{chunk-RDPDU4TA.js → chunk-TMH6HZWF.js} +3 -3
- package/dist/{chunk-6XUS5VFM.js → chunk-YU5PGUK7.js} +6 -4
- package/dist/{chunk-6XUS5VFM.js.map → chunk-YU5PGUK7.js.map} +1 -1
- package/dist/{composer-BXb0Q1HF.d.ts → composer-DoC4veX1.d.ts} +4 -75
- package/dist/composer.d.ts +2 -1
- package/dist/composer.js +4 -3
- package/dist/index.d.ts +14 -259
- package/dist/index.js +1016 -5140
- package/dist/index.js.map +1 -1
- package/dist/machines.js +3 -2
- package/dist/session-Du_FsrZ1.d.ts +264 -0
- package/dist/session-ui-BqQDH7YV.d.ts +183 -0
- package/dist/session-ui.d.ts +7 -0
- package/dist/session-ui.js +17 -0
- package/dist/session-ui.js.map +1 -0
- package/dist/session.d.ts +3 -1
- package/dist/session.js +26 -9
- package/dist/use-file-attachments-C0kpHrs9.d.ts +76 -0
- package/dist/{session-BEvtFWhe.d.ts → use-turn-queue-3rkJwjFv.d.ts} +3 -185
- package/package.json +6 -2
- package/src/components/message-timeline.tsx +92 -52
- package/src/hooks/use-composer.ts +456 -69
- package/src/hooks/use-file-attachments.ts +1 -1
- package/src/hooks/use-goal.ts +1 -1
- package/src/hooks/use-session-events.ts +5 -0
- package/src/hooks/use-session-lineage.ts +1 -1
- package/src/hooks/use-session.ts +9 -6
- package/src/session-ui.ts +13 -0
- package/src/session.ts +15 -0
- package/src/timeline/activity-rail.tsx +1 -1
- package/dist/chunk-AVPU5PMC.js.map +0 -1
- package/dist/chunk-I3BJZIG5.js.map +0 -1
- package/dist/chunk-SHOFILHJ.js.map +0 -1
- /package/dist/{chunk-RDPDU4TA.js.map → chunk-TMH6HZWF.js.map} +0 -0
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
ComposerDraft,
|
|
3
3
|
EffectiveControlResumeOption,
|
|
4
4
|
EffectiveSessionControl,
|
|
5
|
+
OpenGeniApiError,
|
|
5
6
|
ResourceRef,
|
|
6
7
|
SaveComposerDraftRequest,
|
|
7
8
|
SendMessageInput,
|
|
@@ -37,6 +38,184 @@ export type UseComposerOptions = EmbeddedSessionClientOverride &
|
|
|
37
38
|
draftPersistence?: "durable" | "disabled" | undefined;
|
|
38
39
|
};
|
|
39
40
|
|
|
41
|
+
type ComposerDraftShadow = {
|
|
42
|
+
text: string;
|
|
43
|
+
resources: ResourceRef[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type PendingComposerOperation = {
|
|
47
|
+
delivery: "send" | "steer";
|
|
48
|
+
input: SendMessageInput;
|
|
49
|
+
draftAtSend: string;
|
|
50
|
+
resourcesAtSend: ResourceRef[];
|
|
51
|
+
/** Latest local text/resources that must survive an uncertain delivery. */
|
|
52
|
+
newerShadow: ComposerDraftShadow;
|
|
53
|
+
clearDraftOnAccept: boolean;
|
|
54
|
+
/** False after remount when the original input carried secret credentials. */
|
|
55
|
+
canRetry: boolean;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type StoredPendingComposerOperation = Omit<PendingComposerOperation, "input" | "canRetry"> & {
|
|
59
|
+
input: Omit<SendMessageInput, "mcpCredentialUpdates">;
|
|
60
|
+
hasMcpCredentialUpdates: boolean;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const PENDING_COMPOSER_STORAGE_PREFIX = "opengeni.pending-composer.v1:";
|
|
64
|
+
|
|
65
|
+
// A remount must not manufacture a new operation while the previous mutation
|
|
66
|
+
// is still outcome-unknown. Keep only non-credential request fields here; the
|
|
67
|
+
// mounted hook retains the exact input, including any credential updates. The
|
|
68
|
+
// safe shadow is also session-scoped so a refresh cannot lose newer edits.
|
|
69
|
+
const pendingComposerOperations = new Map<string, StoredPendingComposerOperation>();
|
|
70
|
+
|
|
71
|
+
function pendingComposerOperationKey(
|
|
72
|
+
workspaceId: string,
|
|
73
|
+
sessionId: string | null | undefined,
|
|
74
|
+
): string | null {
|
|
75
|
+
return sessionId ? `${workspaceId}\u0000${sessionId}` : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function pendingComposerStorage(): Storage | null {
|
|
79
|
+
try {
|
|
80
|
+
return typeof window === "undefined" ? null : window.sessionStorage;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function pendingComposerStorageKey(key: string): string {
|
|
87
|
+
return `${PENDING_COMPOSER_STORAGE_PREFIX}${encodeURIComponent(key)}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resourceList(value: unknown): value is ResourceRef[] {
|
|
91
|
+
return (
|
|
92
|
+
Array.isArray(value) &&
|
|
93
|
+
value.every((candidate) => {
|
|
94
|
+
if (typeof candidate !== "object" || candidate === null) return false;
|
|
95
|
+
const resource = candidate as Record<string, unknown>;
|
|
96
|
+
return resource.kind === "file"
|
|
97
|
+
? typeof resource.fileId === "string"
|
|
98
|
+
: resource.kind === "repository" &&
|
|
99
|
+
typeof resource.uri === "string" &&
|
|
100
|
+
typeof resource.ref === "string";
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readStoredPendingComposerOperation(
|
|
106
|
+
key: string | null,
|
|
107
|
+
): StoredPendingComposerOperation | null {
|
|
108
|
+
const storage = key ? pendingComposerStorage() : null;
|
|
109
|
+
if (!storage || !key) return null;
|
|
110
|
+
try {
|
|
111
|
+
const parsed: unknown = JSON.parse(storage.getItem(pendingComposerStorageKey(key)) ?? "null");
|
|
112
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
113
|
+
const record = parsed as Record<string, unknown>;
|
|
114
|
+
const input = record.input;
|
|
115
|
+
const shadow = record.newerShadow;
|
|
116
|
+
if (
|
|
117
|
+
typeof input !== "object" ||
|
|
118
|
+
input === null ||
|
|
119
|
+
typeof shadow !== "object" ||
|
|
120
|
+
shadow === null
|
|
121
|
+
) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const inputRecord = input as Record<string, unknown>;
|
|
125
|
+
const shadowRecord = shadow as Record<string, unknown>;
|
|
126
|
+
if (
|
|
127
|
+
(record.delivery !== "send" && record.delivery !== "steer") ||
|
|
128
|
+
typeof record.draftAtSend !== "string" ||
|
|
129
|
+
!resourceList(record.resourcesAtSend) ||
|
|
130
|
+
typeof shadowRecord.text !== "string" ||
|
|
131
|
+
!resourceList(shadowRecord.resources) ||
|
|
132
|
+
typeof record.clearDraftOnAccept !== "boolean" ||
|
|
133
|
+
typeof record.hasMcpCredentialUpdates !== "boolean" ||
|
|
134
|
+
typeof inputRecord.text !== "string" ||
|
|
135
|
+
typeof inputRecord.clientEventId !== "string" ||
|
|
136
|
+
"mcpCredentialUpdates" in inputRecord ||
|
|
137
|
+
("resources" in inputRecord && !resourceList(inputRecord.resources))
|
|
138
|
+
) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
return record as StoredPendingComposerOperation;
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function writePendingComposerOperation(
|
|
148
|
+
key: string,
|
|
149
|
+
operation: StoredPendingComposerOperation,
|
|
150
|
+
): void {
|
|
151
|
+
const storage = pendingComposerStorage();
|
|
152
|
+
if (!storage) return;
|
|
153
|
+
try {
|
|
154
|
+
storage.setItem(pendingComposerStorageKey(key), JSON.stringify(operation));
|
|
155
|
+
} catch {
|
|
156
|
+
// Storage is best effort; the in-memory record still protects remounts.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function restorePendingComposerOperation(key: string | null): PendingComposerOperation | null {
|
|
161
|
+
const stored =
|
|
162
|
+
(key && pendingComposerOperations.get(key)) ?? readStoredPendingComposerOperation(key);
|
|
163
|
+
if (!stored) return null;
|
|
164
|
+
return {
|
|
165
|
+
...stored,
|
|
166
|
+
input: stored.input,
|
|
167
|
+
newerShadow: stored.newerShadow ?? {
|
|
168
|
+
text: stored.draftAtSend,
|
|
169
|
+
resources: stored.resourcesAtSend,
|
|
170
|
+
},
|
|
171
|
+
canRetry: !stored.hasMcpCredentialUpdates,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function rememberPendingComposerOperation(
|
|
176
|
+
key: string | null,
|
|
177
|
+
operation: PendingComposerOperation,
|
|
178
|
+
): void {
|
|
179
|
+
if (!key) return;
|
|
180
|
+
const { canRetry: _retry, ...safeOperation } = operation;
|
|
181
|
+
const { input: originalInput, ...withoutInput } = safeOperation;
|
|
182
|
+
const { mcpCredentialUpdates: _storedMcp, ...safeInput } = originalInput;
|
|
183
|
+
const stored = {
|
|
184
|
+
...withoutInput,
|
|
185
|
+
input: safeInput,
|
|
186
|
+
hasMcpCredentialUpdates: operation.input.mcpCredentialUpdates !== undefined,
|
|
187
|
+
newerShadow: {
|
|
188
|
+
text: operation.newerShadow.text,
|
|
189
|
+
resources: [...operation.newerShadow.resources],
|
|
190
|
+
},
|
|
191
|
+
} satisfies StoredPendingComposerOperation;
|
|
192
|
+
pendingComposerOperations.set(key, stored);
|
|
193
|
+
writePendingComposerOperation(key, stored);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function forgetPendingComposerOperation(key: string | null): void {
|
|
197
|
+
if (!key) return;
|
|
198
|
+
pendingComposerOperations.delete(key);
|
|
199
|
+
const storage = pendingComposerStorage();
|
|
200
|
+
if (!storage) return;
|
|
201
|
+
try {
|
|
202
|
+
storage.removeItem(pendingComposerStorageKey(key));
|
|
203
|
+
} catch {
|
|
204
|
+
// Ignore a blocked storage implementation; delivery has already settled.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function updatePendingComposerShadow(
|
|
209
|
+
key: string | null,
|
|
210
|
+
operation: PendingComposerOperation | null,
|
|
211
|
+
shadow: ComposerDraftShadow,
|
|
212
|
+
): PendingComposerOperation | null {
|
|
213
|
+
if (!key || !operation) return operation;
|
|
214
|
+
const next = { ...operation, newerShadow: { ...shadow, resources: [...shadow.resources] } };
|
|
215
|
+
rememberPendingComposerOperation(key, next);
|
|
216
|
+
return next;
|
|
217
|
+
}
|
|
218
|
+
|
|
40
219
|
export type ComposerState = {
|
|
41
220
|
value: string;
|
|
42
221
|
setValue: (value: string) => void;
|
|
@@ -84,7 +263,10 @@ export function useComposer(
|
|
|
84
263
|
const { client, workspaceId, registerSessionReconciler } = useEmbeddedSession(options);
|
|
85
264
|
const durableDrafts = options.draftPersistence !== "disabled";
|
|
86
265
|
const targetKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${durableDrafts ? "durable" : "disabled"}`;
|
|
87
|
-
const
|
|
266
|
+
const pendingOperationKey = pendingComposerOperationKey(workspaceId, sessionId);
|
|
267
|
+
const initialPendingOperation = restorePendingComposerOperation(pendingOperationKey);
|
|
268
|
+
const initialShadow = initialPendingOperation?.newerShadow;
|
|
269
|
+
const [value, setValue] = useState(() => initialShadow?.text ?? "");
|
|
88
270
|
// Keep rendered state behind the committed target identity for one frame:
|
|
89
271
|
// a parent may switch sessionId without remounting this public hook.
|
|
90
272
|
const [stateTargetKey, setStateTargetKey] = useState(targetKey);
|
|
@@ -96,18 +278,31 @@ export function useComposer(
|
|
|
96
278
|
const [draftLoading, setDraftLoading] = useState(Boolean(sessionId) && durableDrafts);
|
|
97
279
|
const [draftSaving, setDraftSaving] = useState(false);
|
|
98
280
|
const [draftConflict, setDraftConflict] = useState<Error | null>(null);
|
|
99
|
-
const [restoredResources, setRestoredResources] = useState<ResourceRef[]>(
|
|
100
|
-
|
|
101
|
-
|
|
281
|
+
const [restoredResources, setRestoredResources] = useState<ResourceRef[]>(
|
|
282
|
+
() => initialShadow?.resources ?? [],
|
|
283
|
+
);
|
|
284
|
+
const pendingOperationRef = useRef<PendingComposerOperation | null>(initialPendingOperation);
|
|
285
|
+
const pendingClientEventId = useRef<string | null>(
|
|
286
|
+
initialPendingOperation?.input.clientEventId ?? null,
|
|
287
|
+
);
|
|
288
|
+
const valueRef = useRef(initialShadow?.text ?? "");
|
|
102
289
|
const draftRef = useRef<ComposerDraft | null>(null);
|
|
103
|
-
const restoredResourcesRef = useRef<ResourceRef[]>([]);
|
|
104
|
-
const localEditRevision = useRef(0);
|
|
290
|
+
const restoredResourcesRef = useRef<ResourceRef[]>(initialShadow?.resources ?? []);
|
|
291
|
+
const localEditRevision = useRef(initialShadow ? 1 : 0);
|
|
105
292
|
const targetGeneration = useRef(0);
|
|
106
293
|
const draftReadGeneration = useRef(0);
|
|
107
294
|
const lastSavedSignature = useRef<string | null>(null);
|
|
108
295
|
const saveChain = useRef<Promise<void>>(Promise.resolve());
|
|
109
296
|
const onSent = options.onSent;
|
|
110
297
|
const onDraftApplied = options.onDraftApplied;
|
|
298
|
+
// Read through a ref so live session/policy projections can replace their
|
|
299
|
+
// apply callback without invalidating the draft loader and re-running its
|
|
300
|
+
// initial-load effect. Publish only committed callbacks: a suspended target
|
|
301
|
+
// render must not retarget an in-flight read owned by the committed session.
|
|
302
|
+
const onDraftAppliedRef = useRef(onDraftApplied);
|
|
303
|
+
useLayoutEffect(() => {
|
|
304
|
+
onDraftAppliedRef.current = onDraftApplied;
|
|
305
|
+
}, [onDraftApplied]);
|
|
111
306
|
// Read through a ref so a new extras closure (created every render by
|
|
112
307
|
// callers passing inline functions) does not invalidate `send`.
|
|
113
308
|
const sendExtrasRef = useRef(options.sendExtras);
|
|
@@ -124,17 +319,19 @@ export function useComposer(
|
|
|
124
319
|
targetKeyRef.current = targetKey;
|
|
125
320
|
targetGeneration.current += 1;
|
|
126
321
|
draftReadGeneration.current += 1;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
322
|
+
pendingOperationRef.current = restorePendingComposerOperation(pendingOperationKey);
|
|
323
|
+
pendingClientEventId.current = pendingOperationRef.current?.input.clientEventId ?? null;
|
|
324
|
+
const shadow = pendingOperationRef.current?.newerShadow;
|
|
325
|
+
localEditRevision.current = shadow ? 1 : 0;
|
|
326
|
+
valueRef.current = shadow?.text ?? "";
|
|
130
327
|
draftRef.current = null;
|
|
131
|
-
restoredResourcesRef.current = [];
|
|
328
|
+
restoredResourcesRef.current = shadow?.resources ?? [];
|
|
132
329
|
lastSavedSignature.current = null;
|
|
133
330
|
// Old saves may still be awaiting the network. Their generation fence
|
|
134
331
|
// prevents settlement, and a fresh chain avoids blocking this target.
|
|
135
332
|
saveChain.current = Promise.resolve();
|
|
136
333
|
setStateTargetKey(targetKey);
|
|
137
|
-
setValue("");
|
|
334
|
+
setValue(shadow?.text ?? "");
|
|
138
335
|
setSending(false);
|
|
139
336
|
setPausing(false);
|
|
140
337
|
setResuming(false);
|
|
@@ -143,14 +340,13 @@ export function useComposer(
|
|
|
143
340
|
setDraftLoading(Boolean(sessionId) && durableDrafts);
|
|
144
341
|
setDraftSaving(false);
|
|
145
342
|
setDraftConflict(null);
|
|
146
|
-
setRestoredResources([]);
|
|
147
|
-
}, [durableDrafts, sessionId, targetKey]);
|
|
343
|
+
setRestoredResources(shadow?.resources ?? []);
|
|
344
|
+
}, [durableDrafts, pendingOperationKey, sessionId, targetKey]);
|
|
148
345
|
|
|
149
346
|
const applyDraft = useCallback(
|
|
150
347
|
(next: ComposerDraft): void => {
|
|
151
348
|
if (targetKeyRef.current !== targetKey) return;
|
|
152
349
|
if (!durableDrafts) {
|
|
153
|
-
pendingClientEventId.current = null;
|
|
154
350
|
localEditRevision.current += 1;
|
|
155
351
|
valueRef.current = next.text;
|
|
156
352
|
restoredResourcesRef.current = next.resources;
|
|
@@ -159,6 +355,17 @@ export function useComposer(
|
|
|
159
355
|
setDraft(null);
|
|
160
356
|
setValue(next.text);
|
|
161
357
|
setRestoredResources(next.resources);
|
|
358
|
+
pendingOperationRef.current = updatePendingComposerShadow(
|
|
359
|
+
pendingOperationKey,
|
|
360
|
+
pendingOperationRef.current,
|
|
361
|
+
{
|
|
362
|
+
text: next.text,
|
|
363
|
+
resources: mergeResources(
|
|
364
|
+
next.resources,
|
|
365
|
+
resolveSendExtras(sendExtrasRef.current).resources ?? [],
|
|
366
|
+
),
|
|
367
|
+
},
|
|
368
|
+
);
|
|
162
369
|
setDraftConflict(null);
|
|
163
370
|
return;
|
|
164
371
|
}
|
|
@@ -167,14 +374,24 @@ export function useComposer(
|
|
|
167
374
|
restoredResourcesRef.current = next.resources;
|
|
168
375
|
lastSavedSignature.current = draftSignature(draftPayload(next));
|
|
169
376
|
localEditRevision.current += 1;
|
|
170
|
-
pendingClientEventId.current = null;
|
|
171
377
|
setDraft(next);
|
|
172
378
|
setValue(next.text);
|
|
173
379
|
setRestoredResources(next.resources);
|
|
380
|
+
pendingOperationRef.current = updatePendingComposerShadow(
|
|
381
|
+
pendingOperationKey,
|
|
382
|
+
pendingOperationRef.current,
|
|
383
|
+
{
|
|
384
|
+
text: next.text,
|
|
385
|
+
resources: mergeResources(
|
|
386
|
+
next.resources,
|
|
387
|
+
resolveSendExtras(sendExtrasRef.current).resources ?? [],
|
|
388
|
+
),
|
|
389
|
+
},
|
|
390
|
+
);
|
|
174
391
|
setDraftConflict(null);
|
|
175
|
-
|
|
392
|
+
onDraftAppliedRef.current?.(next);
|
|
176
393
|
},
|
|
177
|
-
[durableDrafts,
|
|
394
|
+
[durableDrafts, pendingOperationKey, targetKey],
|
|
178
395
|
);
|
|
179
396
|
|
|
180
397
|
const loadDraft = useCallback(
|
|
@@ -218,7 +435,16 @@ export function useComposer(
|
|
|
218
435
|
draftRef.current = fetched;
|
|
219
436
|
setDraft(fetched);
|
|
220
437
|
setDraftConflict(null);
|
|
221
|
-
|
|
438
|
+
const shadow = pendingOperationRef.current?.newerShadow;
|
|
439
|
+
if (shadow) {
|
|
440
|
+
// The server can only know the original operation. Never replace
|
|
441
|
+
// newer local edits while that operation is still uncertain.
|
|
442
|
+
valueRef.current = shadow.text;
|
|
443
|
+
restoredResourcesRef.current = shadow.resources;
|
|
444
|
+
localEditRevision.current ||= 1;
|
|
445
|
+
setValue(shadow.text);
|
|
446
|
+
setRestoredResources(shadow.resources);
|
|
447
|
+
} else if (
|
|
222
448
|
replaceLocal ||
|
|
223
449
|
(!localWasDirtyAtStart && localAtStart === localEditRevision.current)
|
|
224
450
|
) {
|
|
@@ -227,7 +453,7 @@ export function useComposer(
|
|
|
227
453
|
lastSavedSignature.current = draftSignature(draftPayload(fetched));
|
|
228
454
|
setValue(fetched.text);
|
|
229
455
|
setRestoredResources(fetched.resources);
|
|
230
|
-
|
|
456
|
+
onDraftAppliedRef.current?.(fetched);
|
|
231
457
|
}
|
|
232
458
|
}
|
|
233
459
|
} catch (cause) {
|
|
@@ -248,7 +474,7 @@ export function useComposer(
|
|
|
248
474
|
}
|
|
249
475
|
}
|
|
250
476
|
},
|
|
251
|
-
[client, durableDrafts,
|
|
477
|
+
[client, durableDrafts, sessionId, targetKey, workspaceId],
|
|
252
478
|
);
|
|
253
479
|
|
|
254
480
|
useEffect(() => {
|
|
@@ -325,7 +551,7 @@ export function useComposer(
|
|
|
325
551
|
targetGeneration.current === ownedGeneration
|
|
326
552
|
) {
|
|
327
553
|
const problem = asError(cause);
|
|
328
|
-
setDraftConflict(problem);
|
|
554
|
+
if (isDraftConflictError(problem)) setDraftConflict(problem);
|
|
329
555
|
setError(problem);
|
|
330
556
|
}
|
|
331
557
|
} finally {
|
|
@@ -347,6 +573,27 @@ export function useComposer(
|
|
|
347
573
|
// Private durable autosave. A newer local edit is never replaced by an older
|
|
348
574
|
// response; saves serialize and each reads the latest acknowledged revision.
|
|
349
575
|
useEffect(() => {
|
|
576
|
+
const pending = pendingOperationRef.current;
|
|
577
|
+
if (pending) {
|
|
578
|
+
const shadow = {
|
|
579
|
+
text: valueRef.current,
|
|
580
|
+
resources: mergeResources(
|
|
581
|
+
restoredResourcesRef.current,
|
|
582
|
+
resolveSendExtras(sendExtrasRef.current).resources ?? [],
|
|
583
|
+
),
|
|
584
|
+
};
|
|
585
|
+
if (
|
|
586
|
+
pending.newerShadow.text !== shadow.text ||
|
|
587
|
+
JSON.stringify(pending.newerShadow.resources) !== JSON.stringify(shadow.resources)
|
|
588
|
+
) {
|
|
589
|
+
pendingOperationRef.current = updatePendingComposerShadow(
|
|
590
|
+
pendingOperationKey,
|
|
591
|
+
pending,
|
|
592
|
+
shadow,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
350
597
|
if (
|
|
351
598
|
!durableDrafts ||
|
|
352
599
|
!sessionId ||
|
|
@@ -366,6 +613,7 @@ export function useComposer(
|
|
|
366
613
|
draftConflict,
|
|
367
614
|
draftLoading,
|
|
368
615
|
liveExtrasVersion,
|
|
616
|
+
pendingOperationKey,
|
|
369
617
|
persistPayload,
|
|
370
618
|
sending,
|
|
371
619
|
sessionId,
|
|
@@ -375,15 +623,21 @@ export function useComposer(
|
|
|
375
623
|
async (delivery: "send" | "steer", explicit?: string): Promise<boolean> => {
|
|
376
624
|
const ownedTargetKey = targetKey;
|
|
377
625
|
const ownedGeneration = targetGeneration.current;
|
|
626
|
+
const operationKey = pendingComposerOperationKey(workspaceId, sessionId);
|
|
627
|
+
const pending = pendingOperationRef.current ?? restorePendingComposerOperation(operationKey);
|
|
628
|
+
if (pending && !pendingOperationRef.current) {
|
|
629
|
+
pendingOperationRef.current = pending;
|
|
630
|
+
pendingClientEventId.current = pending.input.clientEventId ?? null;
|
|
631
|
+
}
|
|
378
632
|
const draftAtSend = value;
|
|
379
633
|
const rawText = explicit ?? draftAtSend;
|
|
380
634
|
const hasText = rawText.trim().length > 0;
|
|
381
635
|
// Resolve the extras once: a file-only message (empty text + ≥1 ready
|
|
382
636
|
// resource) is legitimate, so we must not bail on empty text alone.
|
|
383
|
-
const extras = resolveSendExtras(sendExtrasRef.current);
|
|
637
|
+
const extras = pending ? {} : resolveSendExtras(sendExtrasRef.current);
|
|
384
638
|
const hasResources = restoredResources.length > 0 || (extras.resources?.length ?? 0) > 0;
|
|
385
639
|
if (
|
|
386
|
-
(!hasText && !hasResources) ||
|
|
640
|
+
(!pending && !hasText && !hasResources) ||
|
|
387
641
|
!sessionId ||
|
|
388
642
|
sending ||
|
|
389
643
|
sendBlockedRef.current?.() === true ||
|
|
@@ -391,12 +645,117 @@ export function useComposer(
|
|
|
391
645
|
) {
|
|
392
646
|
return false;
|
|
393
647
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
648
|
+
|
|
649
|
+
const clearPending = (): void => {
|
|
650
|
+
pendingOperationRef.current = null;
|
|
651
|
+
pendingClientEventId.current = null;
|
|
652
|
+
forgetPendingComposerOperation(operationKey);
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
const settleAccepted = (operation: PendingComposerOperation): void => {
|
|
656
|
+
clearPending();
|
|
657
|
+
const draftWasUnchanged = valueRef.current === operation.draftAtSend;
|
|
658
|
+
const resourcesWereUnchanged =
|
|
659
|
+
JSON.stringify(restoredResourcesRef.current) ===
|
|
660
|
+
JSON.stringify(operation.resourcesAtSend);
|
|
661
|
+
const previousDraft = draftRef.current;
|
|
662
|
+
if (previousDraft) {
|
|
663
|
+
const cleared = {
|
|
664
|
+
...previousDraft,
|
|
665
|
+
revision: 0,
|
|
666
|
+
text: "",
|
|
667
|
+
resources: [],
|
|
668
|
+
sourceTurnId: null,
|
|
669
|
+
sourceTurnVersion: null,
|
|
670
|
+
updatedAt: null,
|
|
671
|
+
};
|
|
672
|
+
draftRef.current = cleared;
|
|
673
|
+
setDraft(cleared);
|
|
674
|
+
lastSavedSignature.current = draftSignature(draftPayload(cleared));
|
|
675
|
+
}
|
|
676
|
+
if (resourcesWereUnchanged) {
|
|
677
|
+
restoredResourcesRef.current = [];
|
|
678
|
+
setRestoredResources([]);
|
|
679
|
+
}
|
|
680
|
+
if (operation.clearDraftOnAccept && draftWasUnchanged) {
|
|
681
|
+
valueRef.current = "";
|
|
682
|
+
setValue("");
|
|
683
|
+
}
|
|
684
|
+
onSent?.(operation.input.text, operation.input);
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
const deliver = async (operation: PendingComposerOperation): Promise<void> => {
|
|
688
|
+
if (operation.delivery === "steer") {
|
|
689
|
+
await client.steerMessage(workspaceId, sessionId, operation.input);
|
|
690
|
+
} else {
|
|
691
|
+
await client.sendMessage(workspaceId, sessionId, operation.input);
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
|
|
397
695
|
setSending(true);
|
|
398
696
|
setError(null);
|
|
399
697
|
try {
|
|
698
|
+
if (pending) {
|
|
699
|
+
let accepted = false;
|
|
700
|
+
try {
|
|
701
|
+
const events = await client.listEvents(workspaceId, sessionId, {
|
|
702
|
+
includeTypes: ["user.message"],
|
|
703
|
+
limit: 100,
|
|
704
|
+
payloadMode: "none",
|
|
705
|
+
});
|
|
706
|
+
accepted = events.some(
|
|
707
|
+
(event) =>
|
|
708
|
+
event.type === "user.message" &&
|
|
709
|
+
event.clientEventId === pending.input.clientEventId,
|
|
710
|
+
);
|
|
711
|
+
} catch (cause) {
|
|
712
|
+
if (
|
|
713
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
714
|
+
targetGeneration.current === ownedGeneration
|
|
715
|
+
) {
|
|
716
|
+
setError(asError(cause));
|
|
717
|
+
}
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
if (
|
|
721
|
+
targetKeyRef.current !== ownedTargetKey ||
|
|
722
|
+
targetGeneration.current !== ownedGeneration
|
|
723
|
+
) {
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
if (accepted) {
|
|
727
|
+
settleAccepted(pending);
|
|
728
|
+
return true;
|
|
729
|
+
}
|
|
730
|
+
if (!pending.canRetry) {
|
|
731
|
+
setError(
|
|
732
|
+
new Error(
|
|
733
|
+
"OpenGeni cannot safely retry this uncertain request after remount; reconcile the session before sending again.",
|
|
734
|
+
),
|
|
735
|
+
);
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
738
|
+
try {
|
|
739
|
+
await deliver(pending);
|
|
740
|
+
} catch (cause) {
|
|
741
|
+
if (
|
|
742
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
743
|
+
targetGeneration.current === ownedGeneration
|
|
744
|
+
) {
|
|
745
|
+
setError(asError(cause));
|
|
746
|
+
}
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
if (
|
|
750
|
+
targetKeyRef.current !== ownedTargetKey ||
|
|
751
|
+
targetGeneration.current !== ownedGeneration
|
|
752
|
+
) {
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
settleAccepted(pending);
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
|
|
400
759
|
// Trimming is only an emptiness check. A non-blank prompt is persisted
|
|
401
760
|
// and submitted byte-for-byte, while file-only sends use the same
|
|
402
761
|
// placeholder for both operations so the server content fence cannot
|
|
@@ -420,6 +779,7 @@ export function useComposer(
|
|
|
420
779
|
!Object.prototype.hasOwnProperty.call(extras, "tools")
|
|
421
780
|
? { ...extras, tools: acknowledgedDraft.tools }
|
|
422
781
|
: extras;
|
|
782
|
+
pendingClientEventId.current ??= generateClientEventId();
|
|
423
783
|
const input = composeSendInput(sendText, pendingClientEventId.current, sendExtras, {
|
|
424
784
|
...(options.effectiveControl?.controlEtag
|
|
425
785
|
? { controlEtag: options.effectiveControl.controlEtag }
|
|
@@ -429,10 +789,33 @@ export function useComposer(
|
|
|
429
789
|
: {}),
|
|
430
790
|
resources: mergeResources(restoredResources, extras.resources ?? []),
|
|
431
791
|
});
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
792
|
+
const operation: PendingComposerOperation = {
|
|
793
|
+
delivery,
|
|
794
|
+
input,
|
|
795
|
+
draftAtSend,
|
|
796
|
+
resourcesAtSend: [...restoredResources],
|
|
797
|
+
newerShadow: {
|
|
798
|
+
text: draftAtSend,
|
|
799
|
+
resources: mergeResources(restoredResources, extras.resources ?? []),
|
|
800
|
+
},
|
|
801
|
+
clearDraftOnAccept: explicit === undefined,
|
|
802
|
+
canRetry: true,
|
|
803
|
+
};
|
|
804
|
+
pendingOperationRef.current = operation;
|
|
805
|
+
rememberPendingComposerOperation(operationKey, operation);
|
|
806
|
+
try {
|
|
807
|
+
await deliver(operation);
|
|
808
|
+
} catch (cause) {
|
|
809
|
+
if (!isOutcomeUnknownError(cause)) {
|
|
810
|
+
clearPending();
|
|
811
|
+
}
|
|
812
|
+
if (
|
|
813
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
814
|
+
targetGeneration.current === ownedGeneration
|
|
815
|
+
) {
|
|
816
|
+
setError(asError(cause));
|
|
817
|
+
}
|
|
818
|
+
return false;
|
|
436
819
|
}
|
|
437
820
|
if (
|
|
438
821
|
targetKeyRef.current !== ownedTargetKey ||
|
|
@@ -440,42 +823,8 @@ export function useComposer(
|
|
|
440
823
|
) {
|
|
441
824
|
return false;
|
|
442
825
|
}
|
|
443
|
-
|
|
444
|
-
const previousDraft = draftRef.current;
|
|
445
|
-
if (previousDraft) {
|
|
446
|
-
const cleared = {
|
|
447
|
-
...previousDraft,
|
|
448
|
-
revision: 0,
|
|
449
|
-
text: "",
|
|
450
|
-
resources: [],
|
|
451
|
-
sourceTurnId: null,
|
|
452
|
-
sourceTurnVersion: null,
|
|
453
|
-
updatedAt: null,
|
|
454
|
-
};
|
|
455
|
-
draftRef.current = cleared;
|
|
456
|
-
restoredResourcesRef.current = [];
|
|
457
|
-
setDraft(cleared);
|
|
458
|
-
setRestoredResources([]);
|
|
459
|
-
lastSavedSignature.current = draftSignature(draftPayload(cleared));
|
|
460
|
-
}
|
|
461
|
-
if (explicit === undefined) {
|
|
462
|
-
// Clear only the draft that was sent: edits made while the request
|
|
463
|
-
// was in flight were never delivered and must survive.
|
|
464
|
-
if (valueRef.current === draftAtSend) {
|
|
465
|
-
valueRef.current = "";
|
|
466
|
-
setValue("");
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
onSent?.(sendText, input);
|
|
826
|
+
settleAccepted(operation);
|
|
470
827
|
return true;
|
|
471
|
-
} catch (cause) {
|
|
472
|
-
if (
|
|
473
|
-
targetKeyRef.current === ownedTargetKey &&
|
|
474
|
-
targetGeneration.current === ownedGeneration
|
|
475
|
-
) {
|
|
476
|
-
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
477
|
-
}
|
|
478
|
-
return false;
|
|
479
828
|
} finally {
|
|
480
829
|
if (
|
|
481
830
|
targetKeyRef.current === ownedTargetKey &&
|
|
@@ -512,6 +861,7 @@ export function useComposer(
|
|
|
512
861
|
const hasReadyResources =
|
|
513
862
|
restoredResources.length > 0 ||
|
|
514
863
|
(resolveSendExtras(sendExtrasRef.current).resources?.length ?? 0) > 0;
|
|
864
|
+
const hasPendingOperation = pendingOperationRef.current !== null;
|
|
515
865
|
|
|
516
866
|
const pause = useCallback(
|
|
517
867
|
async (reason?: string): Promise<void> => {
|
|
@@ -643,12 +993,22 @@ export function useComposer(
|
|
|
643
993
|
const updateValue = useCallback(
|
|
644
994
|
(next: string) => {
|
|
645
995
|
if (targetKeyRef.current !== targetKey) return;
|
|
646
|
-
pendingClientEventId.current = null;
|
|
647
996
|
localEditRevision.current += 1;
|
|
648
997
|
valueRef.current = next;
|
|
998
|
+
pendingOperationRef.current = updatePendingComposerShadow(
|
|
999
|
+
pendingOperationKey,
|
|
1000
|
+
pendingOperationRef.current,
|
|
1001
|
+
{
|
|
1002
|
+
text: next,
|
|
1003
|
+
resources: mergeResources(
|
|
1004
|
+
restoredResourcesRef.current,
|
|
1005
|
+
resolveSendExtras(sendExtrasRef.current).resources ?? [],
|
|
1006
|
+
),
|
|
1007
|
+
},
|
|
1008
|
+
);
|
|
649
1009
|
setValue(next);
|
|
650
1010
|
},
|
|
651
|
-
[targetKey],
|
|
1011
|
+
[pendingOperationKey, targetKey],
|
|
652
1012
|
);
|
|
653
1013
|
|
|
654
1014
|
const removeRestoredResource = useCallback(
|
|
@@ -657,9 +1017,17 @@ export function useComposer(
|
|
|
657
1017
|
localEditRevision.current += 1;
|
|
658
1018
|
const next = restoredResourcesRef.current.filter((_, candidate) => candidate !== index);
|
|
659
1019
|
restoredResourcesRef.current = next;
|
|
1020
|
+
pendingOperationRef.current = updatePendingComposerShadow(
|
|
1021
|
+
pendingOperationKey,
|
|
1022
|
+
pendingOperationRef.current,
|
|
1023
|
+
{
|
|
1024
|
+
text: valueRef.current,
|
|
1025
|
+
resources: mergeResources(next, resolveSendExtras(sendExtrasRef.current).resources ?? []),
|
|
1026
|
+
},
|
|
1027
|
+
);
|
|
660
1028
|
setRestoredResources(next);
|
|
661
1029
|
},
|
|
662
|
-
[targetKey],
|
|
1030
|
+
[pendingOperationKey, targetKey],
|
|
663
1031
|
);
|
|
664
1032
|
|
|
665
1033
|
const hasDraftContent = useCallback((): boolean => {
|
|
@@ -727,7 +1095,7 @@ export function useComposer(
|
|
|
727
1095
|
Boolean(sessionId) &&
|
|
728
1096
|
!sending &&
|
|
729
1097
|
sendBlockedRef.current?.() !== true &&
|
|
730
|
-
(value.trim().length > 0 || hasReadyResources),
|
|
1098
|
+
(hasPendingOperation || value.trim().length > 0 || hasReadyResources),
|
|
731
1099
|
pause,
|
|
732
1100
|
pausing: identityMatches ? pausing : false,
|
|
733
1101
|
resume,
|
|
@@ -805,10 +1173,29 @@ function generateClientEventId(): string {
|
|
|
805
1173
|
return globalThis.crypto.randomUUID();
|
|
806
1174
|
}
|
|
807
1175
|
|
|
1176
|
+
function isOutcomeUnknownError(cause: unknown): boolean {
|
|
1177
|
+
return (
|
|
1178
|
+
typeof cause === "object" &&
|
|
1179
|
+
cause !== null &&
|
|
1180
|
+
(cause as { outcomeUnknown?: unknown }).outcomeUnknown === true
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
808
1184
|
function asError(cause: unknown): Error {
|
|
809
1185
|
return cause instanceof Error ? cause : new Error(String(cause));
|
|
810
1186
|
}
|
|
811
1187
|
|
|
1188
|
+
function isDraftConflictError(error: Error): boolean {
|
|
1189
|
+
const apiError = error as Partial<OpenGeniApiError>;
|
|
1190
|
+
return (
|
|
1191
|
+
apiError.status === 409 &&
|
|
1192
|
+
apiError.outcomeUnknown === false &&
|
|
1193
|
+
(apiError.code === undefined ||
|
|
1194
|
+
apiError.code === "conflict" ||
|
|
1195
|
+
apiError.code === "idempotency_conflict")
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
812
1199
|
function draftPayload(draft: ComposerDraft): SaveComposerDraftRequest {
|
|
813
1200
|
return {
|
|
814
1201
|
expectedRevision: draft.revision,
|