@opengeni/react 0.23.0 → 0.25.5
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-UQS7OQDE.js → chunk-F4CFWKVI.js} +334 -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-UAHPKLB6.js → chunk-MP6237JB.js} +571 -975
- 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-CATATY77.js → chunk-TMH6HZWF.js} +8 -7
- package/dist/chunk-TMH6HZWF.js.map +1 -0
- 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-CVm5uoUI.d.ts → composer-DoC4veX1.d.ts} +6 -63
- package/dist/composer.d.ts +3 -2
- package/dist/composer.js +4 -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 +17 -259
- package/dist/index.js +1042 -5058
- 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 +4 -2
- package/dist/session.js +26 -9
- package/dist/{use-composer-By1wn3MM.d.ts → use-composer-BnEzheSk.d.ts} +8 -2
- package/dist/use-file-attachments-C0kpHrs9.d.ts +76 -0
- package/dist/use-turn-queue-3rkJwjFv.d.ts +290 -0
- package/package.json +6 -2
- package/src/components/composer.tsx +7 -6
- package/src/components/message-timeline.tsx +94 -54
- package/src/hooks/use-composer.ts +470 -73
- package/src/hooks/use-file-attachments.ts +96 -3
- package/src/hooks/use-goal.ts +23 -5
- 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/hooks/use-workspace-sessions.ts +30 -5
- package/src/session-ui.ts +13 -0
- package/src/session.ts +15 -0
- package/src/timeline/activity-rail.tsx +18 -2
- 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 +68 -1
- package/dist/chunk-CATATY77.js.map +0 -1
- package/dist/chunk-I3BJZIG5.js.map +0 -1
- package/dist/chunk-UAHPKLB6.js.map +0 -1
- package/dist/chunk-UQS7OQDE.js.map +0 -1
- package/dist/session-Db00TRwl.d.ts +0 -446
|
@@ -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,
|
|
@@ -15,14 +16,20 @@ export type ComposerSendExtras = Omit<SendMessageInput, "text" | "clientEventId"
|
|
|
15
16
|
|
|
16
17
|
export type UseComposerOptions = EmbeddedSessionClientOverride &
|
|
17
18
|
SessionEventFeedOptions & {
|
|
18
|
-
/** Called with the accepted
|
|
19
|
-
onSent?: ((text: string) => void) | undefined;
|
|
19
|
+
/** Called with the exact accepted wire input after a successful send. */
|
|
20
|
+
onSent?: ((text: string, input: SendMessageInput) => void) | undefined;
|
|
20
21
|
/**
|
|
21
22
|
* Extra message fields (resources, tools, model, reasoningEffort) merged
|
|
22
23
|
* into every send. A function is evaluated at send time so it can read the
|
|
23
24
|
* surrounding UI state (attachment pickers, model selectors, ...).
|
|
24
25
|
*/
|
|
25
26
|
sendExtras?: ComposerSendExtras | (() => ComposerSendExtras) | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Fail-closed delivery guard evaluated at send time. Attachment hosts use
|
|
29
|
+
* this to preserve unresolved upload cards until the operator waits,
|
|
30
|
+
* retries, or removes them; direct hook callers cannot bypass the UI gate.
|
|
31
|
+
*/
|
|
32
|
+
sendBlocked?: (() => boolean) | undefined;
|
|
26
33
|
/** Latest server-derived workstream control; bound into Send/Steer OCC. */
|
|
27
34
|
effectiveControl?: EffectiveSessionControl | null | undefined;
|
|
28
35
|
/** Apply durable model/tool/reasoning settings in the host's controlled UI. */
|
|
@@ -31,6 +38,184 @@ export type UseComposerOptions = EmbeddedSessionClientOverride &
|
|
|
31
38
|
draftPersistence?: "durable" | "disabled" | undefined;
|
|
32
39
|
};
|
|
33
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
|
+
|
|
34
219
|
export type ComposerState = {
|
|
35
220
|
value: string;
|
|
36
221
|
setValue: (value: string) => void;
|
|
@@ -78,7 +263,10 @@ export function useComposer(
|
|
|
78
263
|
const { client, workspaceId, registerSessionReconciler } = useEmbeddedSession(options);
|
|
79
264
|
const durableDrafts = options.draftPersistence !== "disabled";
|
|
80
265
|
const targetKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${durableDrafts ? "durable" : "disabled"}`;
|
|
81
|
-
const
|
|
266
|
+
const pendingOperationKey = pendingComposerOperationKey(workspaceId, sessionId);
|
|
267
|
+
const initialPendingOperation = restorePendingComposerOperation(pendingOperationKey);
|
|
268
|
+
const initialShadow = initialPendingOperation?.newerShadow;
|
|
269
|
+
const [value, setValue] = useState(() => initialShadow?.text ?? "");
|
|
82
270
|
// Keep rendered state behind the committed target identity for one frame:
|
|
83
271
|
// a parent may switch sessionId without remounting this public hook.
|
|
84
272
|
const [stateTargetKey, setStateTargetKey] = useState(targetKey);
|
|
@@ -90,22 +278,37 @@ export function useComposer(
|
|
|
90
278
|
const [draftLoading, setDraftLoading] = useState(Boolean(sessionId) && durableDrafts);
|
|
91
279
|
const [draftSaving, setDraftSaving] = useState(false);
|
|
92
280
|
const [draftConflict, setDraftConflict] = useState<Error | null>(null);
|
|
93
|
-
const [restoredResources, setRestoredResources] = useState<ResourceRef[]>(
|
|
94
|
-
|
|
95
|
-
|
|
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 ?? "");
|
|
96
289
|
const draftRef = useRef<ComposerDraft | null>(null);
|
|
97
|
-
const restoredResourcesRef = useRef<ResourceRef[]>([]);
|
|
98
|
-
const localEditRevision = useRef(0);
|
|
290
|
+
const restoredResourcesRef = useRef<ResourceRef[]>(initialShadow?.resources ?? []);
|
|
291
|
+
const localEditRevision = useRef(initialShadow ? 1 : 0);
|
|
99
292
|
const targetGeneration = useRef(0);
|
|
100
293
|
const draftReadGeneration = useRef(0);
|
|
101
294
|
const lastSavedSignature = useRef<string | null>(null);
|
|
102
295
|
const saveChain = useRef<Promise<void>>(Promise.resolve());
|
|
103
296
|
const onSent = options.onSent;
|
|
104
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]);
|
|
105
306
|
// Read through a ref so a new extras closure (created every render by
|
|
106
307
|
// callers passing inline functions) does not invalidate `send`.
|
|
107
308
|
const sendExtrasRef = useRef(options.sendExtras);
|
|
108
309
|
sendExtrasRef.current = options.sendExtras;
|
|
310
|
+
const sendBlockedRef = useRef(options.sendBlocked);
|
|
311
|
+
sendBlockedRef.current = options.sendBlocked;
|
|
109
312
|
const liveExtrasVersion = JSON.stringify(resolveSendExtras(options.sendExtras));
|
|
110
313
|
|
|
111
314
|
// A composer is bound to one session: switching targets must not leak the
|
|
@@ -116,17 +319,19 @@ export function useComposer(
|
|
|
116
319
|
targetKeyRef.current = targetKey;
|
|
117
320
|
targetGeneration.current += 1;
|
|
118
321
|
draftReadGeneration.current += 1;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 ?? "";
|
|
122
327
|
draftRef.current = null;
|
|
123
|
-
restoredResourcesRef.current = [];
|
|
328
|
+
restoredResourcesRef.current = shadow?.resources ?? [];
|
|
124
329
|
lastSavedSignature.current = null;
|
|
125
330
|
// Old saves may still be awaiting the network. Their generation fence
|
|
126
331
|
// prevents settlement, and a fresh chain avoids blocking this target.
|
|
127
332
|
saveChain.current = Promise.resolve();
|
|
128
333
|
setStateTargetKey(targetKey);
|
|
129
|
-
setValue("");
|
|
334
|
+
setValue(shadow?.text ?? "");
|
|
130
335
|
setSending(false);
|
|
131
336
|
setPausing(false);
|
|
132
337
|
setResuming(false);
|
|
@@ -135,14 +340,13 @@ export function useComposer(
|
|
|
135
340
|
setDraftLoading(Boolean(sessionId) && durableDrafts);
|
|
136
341
|
setDraftSaving(false);
|
|
137
342
|
setDraftConflict(null);
|
|
138
|
-
setRestoredResources([]);
|
|
139
|
-
}, [durableDrafts, sessionId, targetKey]);
|
|
343
|
+
setRestoredResources(shadow?.resources ?? []);
|
|
344
|
+
}, [durableDrafts, pendingOperationKey, sessionId, targetKey]);
|
|
140
345
|
|
|
141
346
|
const applyDraft = useCallback(
|
|
142
347
|
(next: ComposerDraft): void => {
|
|
143
348
|
if (targetKeyRef.current !== targetKey) return;
|
|
144
349
|
if (!durableDrafts) {
|
|
145
|
-
pendingClientEventId.current = null;
|
|
146
350
|
localEditRevision.current += 1;
|
|
147
351
|
valueRef.current = next.text;
|
|
148
352
|
restoredResourcesRef.current = next.resources;
|
|
@@ -151,6 +355,17 @@ export function useComposer(
|
|
|
151
355
|
setDraft(null);
|
|
152
356
|
setValue(next.text);
|
|
153
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
|
+
);
|
|
154
369
|
setDraftConflict(null);
|
|
155
370
|
return;
|
|
156
371
|
}
|
|
@@ -159,14 +374,24 @@ export function useComposer(
|
|
|
159
374
|
restoredResourcesRef.current = next.resources;
|
|
160
375
|
lastSavedSignature.current = draftSignature(draftPayload(next));
|
|
161
376
|
localEditRevision.current += 1;
|
|
162
|
-
pendingClientEventId.current = null;
|
|
163
377
|
setDraft(next);
|
|
164
378
|
setValue(next.text);
|
|
165
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
|
+
);
|
|
166
391
|
setDraftConflict(null);
|
|
167
|
-
|
|
392
|
+
onDraftAppliedRef.current?.(next);
|
|
168
393
|
},
|
|
169
|
-
[durableDrafts,
|
|
394
|
+
[durableDrafts, pendingOperationKey, targetKey],
|
|
170
395
|
);
|
|
171
396
|
|
|
172
397
|
const loadDraft = useCallback(
|
|
@@ -210,7 +435,16 @@ export function useComposer(
|
|
|
210
435
|
draftRef.current = fetched;
|
|
211
436
|
setDraft(fetched);
|
|
212
437
|
setDraftConflict(null);
|
|
213
|
-
|
|
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 (
|
|
214
448
|
replaceLocal ||
|
|
215
449
|
(!localWasDirtyAtStart && localAtStart === localEditRevision.current)
|
|
216
450
|
) {
|
|
@@ -219,7 +453,7 @@ export function useComposer(
|
|
|
219
453
|
lastSavedSignature.current = draftSignature(draftPayload(fetched));
|
|
220
454
|
setValue(fetched.text);
|
|
221
455
|
setRestoredResources(fetched.resources);
|
|
222
|
-
|
|
456
|
+
onDraftAppliedRef.current?.(fetched);
|
|
223
457
|
}
|
|
224
458
|
}
|
|
225
459
|
} catch (cause) {
|
|
@@ -240,7 +474,7 @@ export function useComposer(
|
|
|
240
474
|
}
|
|
241
475
|
}
|
|
242
476
|
},
|
|
243
|
-
[client, durableDrafts,
|
|
477
|
+
[client, durableDrafts, sessionId, targetKey, workspaceId],
|
|
244
478
|
);
|
|
245
479
|
|
|
246
480
|
useEffect(() => {
|
|
@@ -317,7 +551,7 @@ export function useComposer(
|
|
|
317
551
|
targetGeneration.current === ownedGeneration
|
|
318
552
|
) {
|
|
319
553
|
const problem = asError(cause);
|
|
320
|
-
setDraftConflict(problem);
|
|
554
|
+
if (isDraftConflictError(problem)) setDraftConflict(problem);
|
|
321
555
|
setError(problem);
|
|
322
556
|
}
|
|
323
557
|
} finally {
|
|
@@ -339,6 +573,27 @@ export function useComposer(
|
|
|
339
573
|
// Private durable autosave. A newer local edit is never replaced by an older
|
|
340
574
|
// response; saves serialize and each reads the latest acknowledged revision.
|
|
341
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
|
+
}
|
|
342
597
|
if (
|
|
343
598
|
!durableDrafts ||
|
|
344
599
|
!sessionId ||
|
|
@@ -358,6 +613,7 @@ export function useComposer(
|
|
|
358
613
|
draftConflict,
|
|
359
614
|
draftLoading,
|
|
360
615
|
liveExtrasVersion,
|
|
616
|
+
pendingOperationKey,
|
|
361
617
|
persistPayload,
|
|
362
618
|
sending,
|
|
363
619
|
sessionId,
|
|
@@ -367,27 +623,139 @@ export function useComposer(
|
|
|
367
623
|
async (delivery: "send" | "steer", explicit?: string): Promise<boolean> => {
|
|
368
624
|
const ownedTargetKey = targetKey;
|
|
369
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
|
+
}
|
|
370
632
|
const draftAtSend = value;
|
|
371
633
|
const rawText = explicit ?? draftAtSend;
|
|
372
634
|
const hasText = rawText.trim().length > 0;
|
|
373
635
|
// Resolve the extras once: a file-only message (empty text + ≥1 ready
|
|
374
636
|
// resource) is legitimate, so we must not bail on empty text alone.
|
|
375
|
-
const extras = resolveSendExtras(sendExtrasRef.current);
|
|
637
|
+
const extras = pending ? {} : resolveSendExtras(sendExtrasRef.current);
|
|
376
638
|
const hasResources = restoredResources.length > 0 || (extras.resources?.length ?? 0) > 0;
|
|
377
639
|
if (
|
|
378
|
-
(!hasText && !hasResources) ||
|
|
640
|
+
(!pending && !hasText && !hasResources) ||
|
|
379
641
|
!sessionId ||
|
|
380
642
|
sending ||
|
|
643
|
+
sendBlockedRef.current?.() === true ||
|
|
381
644
|
targetKeyRef.current !== ownedTargetKey
|
|
382
645
|
) {
|
|
383
646
|
return false;
|
|
384
647
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
+
|
|
388
695
|
setSending(true);
|
|
389
696
|
setError(null);
|
|
390
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
|
+
|
|
391
759
|
// Trimming is only an emptiness check. A non-blank prompt is persisted
|
|
392
760
|
// and submitted byte-for-byte, while file-only sends use the same
|
|
393
761
|
// placeholder for both operations so the server content fence cannot
|
|
@@ -411,6 +779,7 @@ export function useComposer(
|
|
|
411
779
|
!Object.prototype.hasOwnProperty.call(extras, "tools")
|
|
412
780
|
? { ...extras, tools: acknowledgedDraft.tools }
|
|
413
781
|
: extras;
|
|
782
|
+
pendingClientEventId.current ??= generateClientEventId();
|
|
414
783
|
const input = composeSendInput(sendText, pendingClientEventId.current, sendExtras, {
|
|
415
784
|
...(options.effectiveControl?.controlEtag
|
|
416
785
|
? { controlEtag: options.effectiveControl.controlEtag }
|
|
@@ -420,10 +789,33 @@ export function useComposer(
|
|
|
420
789
|
: {}),
|
|
421
790
|
resources: mergeResources(restoredResources, extras.resources ?? []),
|
|
422
791
|
});
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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;
|
|
427
819
|
}
|
|
428
820
|
if (
|
|
429
821
|
targetKeyRef.current !== ownedTargetKey ||
|
|
@@ -431,42 +823,8 @@ export function useComposer(
|
|
|
431
823
|
) {
|
|
432
824
|
return false;
|
|
433
825
|
}
|
|
434
|
-
|
|
435
|
-
const previousDraft = draftRef.current;
|
|
436
|
-
if (previousDraft) {
|
|
437
|
-
const cleared = {
|
|
438
|
-
...previousDraft,
|
|
439
|
-
revision: 0,
|
|
440
|
-
text: "",
|
|
441
|
-
resources: [],
|
|
442
|
-
sourceTurnId: null,
|
|
443
|
-
sourceTurnVersion: null,
|
|
444
|
-
updatedAt: null,
|
|
445
|
-
};
|
|
446
|
-
draftRef.current = cleared;
|
|
447
|
-
restoredResourcesRef.current = [];
|
|
448
|
-
setDraft(cleared);
|
|
449
|
-
setRestoredResources([]);
|
|
450
|
-
lastSavedSignature.current = draftSignature(draftPayload(cleared));
|
|
451
|
-
}
|
|
452
|
-
if (explicit === undefined) {
|
|
453
|
-
// Clear only the draft that was sent: edits made while the request
|
|
454
|
-
// was in flight were never delivered and must survive.
|
|
455
|
-
if (valueRef.current === draftAtSend) {
|
|
456
|
-
valueRef.current = "";
|
|
457
|
-
setValue("");
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
onSent?.(sendText);
|
|
826
|
+
settleAccepted(operation);
|
|
461
827
|
return true;
|
|
462
|
-
} catch (cause) {
|
|
463
|
-
if (
|
|
464
|
-
targetKeyRef.current === ownedTargetKey &&
|
|
465
|
-
targetGeneration.current === ownedGeneration
|
|
466
|
-
) {
|
|
467
|
-
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
468
|
-
}
|
|
469
|
-
return false;
|
|
470
828
|
} finally {
|
|
471
829
|
if (
|
|
472
830
|
targetKeyRef.current === ownedTargetKey &&
|
|
@@ -498,11 +856,12 @@ export function useComposer(
|
|
|
498
856
|
// A send is possible with non-empty text OR with ≥1 attached resource (a
|
|
499
857
|
// file-only message). Resources ride in `sendExtras`, so we resolve them here
|
|
500
858
|
// — keeping useComposer attachment-agnostic while still lighting up the send
|
|
501
|
-
// affordance the moment a file is ready.
|
|
502
|
-
//
|
|
859
|
+
// affordance the moment a file is ready. Attachment hosts bind `sendBlocked`
|
|
860
|
+
// to unresolved uploads so direct send()/steer() calls fail closed too.
|
|
503
861
|
const hasReadyResources =
|
|
504
862
|
restoredResources.length > 0 ||
|
|
505
863
|
(resolveSendExtras(sendExtrasRef.current).resources?.length ?? 0) > 0;
|
|
864
|
+
const hasPendingOperation = pendingOperationRef.current !== null;
|
|
506
865
|
|
|
507
866
|
const pause = useCallback(
|
|
508
867
|
async (reason?: string): Promise<void> => {
|
|
@@ -634,12 +993,22 @@ export function useComposer(
|
|
|
634
993
|
const updateValue = useCallback(
|
|
635
994
|
(next: string) => {
|
|
636
995
|
if (targetKeyRef.current !== targetKey) return;
|
|
637
|
-
pendingClientEventId.current = null;
|
|
638
996
|
localEditRevision.current += 1;
|
|
639
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
|
+
);
|
|
640
1009
|
setValue(next);
|
|
641
1010
|
},
|
|
642
|
-
[targetKey],
|
|
1011
|
+
[pendingOperationKey, targetKey],
|
|
643
1012
|
);
|
|
644
1013
|
|
|
645
1014
|
const removeRestoredResource = useCallback(
|
|
@@ -648,9 +1017,17 @@ export function useComposer(
|
|
|
648
1017
|
localEditRevision.current += 1;
|
|
649
1018
|
const next = restoredResourcesRef.current.filter((_, candidate) => candidate !== index);
|
|
650
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
|
+
);
|
|
651
1028
|
setRestoredResources(next);
|
|
652
1029
|
},
|
|
653
|
-
[targetKey],
|
|
1030
|
+
[pendingOperationKey, targetKey],
|
|
654
1031
|
);
|
|
655
1032
|
|
|
656
1033
|
const hasDraftContent = useCallback((): boolean => {
|
|
@@ -717,7 +1094,8 @@ export function useComposer(
|
|
|
717
1094
|
identityMatches &&
|
|
718
1095
|
Boolean(sessionId) &&
|
|
719
1096
|
!sending &&
|
|
720
|
-
|
|
1097
|
+
sendBlockedRef.current?.() !== true &&
|
|
1098
|
+
(hasPendingOperation || value.trim().length > 0 || hasReadyResources),
|
|
721
1099
|
pause,
|
|
722
1100
|
pausing: identityMatches ? pausing : false,
|
|
723
1101
|
resume,
|
|
@@ -795,10 +1173,29 @@ function generateClientEventId(): string {
|
|
|
795
1173
|
return globalThis.crypto.randomUUID();
|
|
796
1174
|
}
|
|
797
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
|
+
|
|
798
1184
|
function asError(cause: unknown): Error {
|
|
799
1185
|
return cause instanceof Error ? cause : new Error(String(cause));
|
|
800
1186
|
}
|
|
801
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
|
+
|
|
802
1199
|
function draftPayload(draft: ComposerDraft): SaveComposerDraftRequest {
|
|
803
1200
|
return {
|
|
804
1201
|
expectedRevision: draft.revision,
|