@opengeni/react 0.15.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -10
- package/dist/{chunk-TOJR776I.js → chunk-6BXBGJ3B.js} +45 -369
- package/dist/chunk-6BXBGJ3B.js.map +1 -0
- package/dist/chunk-6UT5OC3P.js +2133 -0
- package/dist/chunk-6UT5OC3P.js.map +1 -0
- package/dist/chunk-EBTT3MYN.js +92 -0
- package/dist/chunk-EBTT3MYN.js.map +1 -0
- package/dist/chunk-HHFA4AFX.js +425 -0
- package/dist/chunk-HHFA4AFX.js.map +1 -0
- package/dist/chunk-LAZ2A743.js +2393 -0
- package/dist/chunk-LAZ2A743.js.map +1 -0
- package/dist/chunk-MRSECXRP.js +563 -0
- package/dist/chunk-MRSECXRP.js.map +1 -0
- package/dist/chunk-TK7G6XLT.js +18 -0
- package/dist/chunk-TK7G6XLT.js.map +1 -0
- package/dist/composer-DvUaa5ki.d.ts +585 -0
- package/dist/composer.d.ts +6 -0
- package/dist/composer.js +56 -0
- package/dist/composer.js.map +1 -0
- package/dist/index.d.ts +1288 -1785
- package/dist/index.js +7015 -8806
- package/dist/index.js.map +1 -1
- package/dist/machines.d.ts +412 -3
- package/dist/machines.js +3 -1
- package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
- package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
- package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
- package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
- package/dist/queue-surface-implementation-NQMV2ML3.js +890 -0
- package/dist/queue-surface-implementation-NQMV2ML3.js.map +1 -0
- package/dist/session-Gd4iVPYw.d.ts +425 -0
- package/dist/session-context-DIFFlXKc.d.ts +52 -0
- package/dist/session.d.ts +4 -0
- package/dist/session.js +50 -0
- package/dist/session.js.map +1 -0
- package/dist/use-composer-BCfm4mzX.d.ts +112 -0
- package/package.json +20 -4
- package/src/approvals.ts +5 -3
- package/src/client.ts +40 -0
- package/src/components/approval-surface.tsx +198 -0
- package/src/components/chat-composer.tsx +94 -993
- package/src/components/code-editor.tsx +104 -26
- package/src/components/command-palette.tsx +8 -2
- package/src/components/composer-transcription-control.tsx +211 -0
- package/src/components/composer.tsx +1536 -0
- package/src/components/desktop-viewer.tsx +3 -3
- package/src/components/enrollment-consent.tsx +2 -2
- package/src/components/file-browser.tsx +367 -29
- package/src/components/human-input-form.tsx +417 -0
- package/src/components/machine-card.tsx +1 -1
- package/src/components/machines-dashboard.tsx +3 -3
- package/src/components/message-timeline.tsx +15 -7
- package/src/components/model-picker.tsx +12 -3
- package/src/components/pierre-diff.tsx +32 -6
- package/src/components/queue-surface-implementation.tsx +1041 -0
- package/src/components/queue-surface-state.tsx +94 -0
- package/src/components/queue-surface.tsx +41 -559
- package/src/components/sandbox-files.tsx +138 -14
- package/src/components/sandbox-terminal.tsx +8 -2
- package/src/components/sandbox-workspace.tsx +339 -119
- package/src/components/workbench-changes.tsx +84 -33
- package/src/components/workspace-dock.tsx +188 -18
- package/src/composer.ts +60 -0
- package/src/hooks/internal.ts +115 -34
- package/src/hooks/use-composer.ts +335 -104
- package/src/hooks/use-human-input.ts +131 -0
- package/src/hooks/use-machine-chip.ts +2 -2
- package/src/hooks/use-machines.ts +27 -11
- package/src/hooks/use-sandbox-files.ts +310 -62
- package/src/hooks/use-sandbox-git.ts +154 -40
- package/src/hooks/use-sandbox-terminal.ts +28 -6
- package/src/hooks/use-session-capabilities.ts +38 -9
- package/src/hooks/use-session-control.ts +37 -8
- package/src/hooks/use-session-events.ts +522 -48
- package/src/hooks/use-slash-commands.ts +38 -38
- package/src/hooks/use-transcription.ts +757 -0
- package/src/hooks/use-turn-queue.ts +134 -66
- package/src/hooks/use-windowed-sections.ts +2 -2
- package/src/hooks/use-workspace-capture.ts +146 -40
- package/src/hooks/use-workspace-edit.ts +50 -14
- package/src/hooks/use-workspace-sessions.ts +3 -0
- package/src/human-input.ts +72 -0
- package/src/index.ts +76 -6
- package/src/lib/noto-sans-arabic-loader.ts +21 -0
- package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
- package/src/lib/use-portal-token-style.ts +51 -0
- package/src/lib/use-unicode-fonts.ts +57 -0
- package/src/provider.tsx +4 -69
- package/src/session-context.ts +135 -0
- package/src/session.ts +79 -0
- package/src/timeline/parsers.ts +35 -0
- package/src/timeline/projection.ts +12 -4
- package/src/timeline/tool-renderers.tsx +41 -0
- package/src/timeline/turn-summary.tsx +2 -2
- package/src/timeline/types.ts +16 -8
- package/styles/index.css +56 -0
- package/styles/index.d.ts +2 -0
- package/styles/tokens.css +11 -6
- package/styles/tokens.d.ts +2 -0
- package/dist/chunk-TOJR776I.js.map +0 -1
- package/dist/machines-BpdwuQcD.d.ts +0 -449
|
@@ -7,13 +7,13 @@ import type {
|
|
|
7
7
|
SendMessageInput,
|
|
8
8
|
SessionEvent,
|
|
9
9
|
} from "@opengeni/sdk";
|
|
10
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
11
|
-
import {
|
|
10
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
11
|
+
import { useEmbeddedSession, type EmbeddedSessionClientOverride } from "../session-context";
|
|
12
12
|
import { useSessionEventTrigger, type SessionEventFeedOptions } from "./internal";
|
|
13
13
|
|
|
14
14
|
export type ComposerSendExtras = Omit<SendMessageInput, "text" | "clientEventId">;
|
|
15
15
|
|
|
16
|
-
export type UseComposerOptions =
|
|
16
|
+
export type UseComposerOptions = EmbeddedSessionClientOverride &
|
|
17
17
|
SessionEventFeedOptions & {
|
|
18
18
|
/** Called with the accepted text after a successful send. */
|
|
19
19
|
onSent?: ((text: string) => void) | undefined;
|
|
@@ -27,6 +27,18 @@ export type UseComposerOptions = ClientOverride &
|
|
|
27
27
|
effectiveControl?: EffectiveSessionControl | null | undefined;
|
|
28
28
|
/** Apply durable model/tool/reasoning settings in the host's controlled UI. */
|
|
29
29
|
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
|
+
*/
|
|
41
|
+
draftPersistence?: "durable" | "disabled" | undefined;
|
|
30
42
|
};
|
|
31
43
|
|
|
32
44
|
export type ComposerState = {
|
|
@@ -49,6 +61,13 @@ export type ComposerState = {
|
|
|
49
61
|
draftLoading: boolean;
|
|
50
62
|
draftSaving: boolean;
|
|
51
63
|
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
|
+
*/
|
|
70
|
+
draftPersistence?: "durable" | "disabled" | undefined;
|
|
52
71
|
/** Apply an atomic queue Edit checkout without a second read. */
|
|
53
72
|
applyDraft: (draft: ComposerDraft) => void;
|
|
54
73
|
reloadDraft: () => Promise<void>;
|
|
@@ -69,19 +88,24 @@ export function useComposer(
|
|
|
69
88
|
sessionId: string | null | undefined,
|
|
70
89
|
options: UseComposerOptions = {},
|
|
71
90
|
): ComposerState {
|
|
72
|
-
const { client, workspaceId, registerSessionReconciler } =
|
|
91
|
+
const { client, workspaceId, registerSessionReconciler } = useEmbeddedSession(options);
|
|
92
|
+
const durableDrafts = options.draftPersistence !== "disabled";
|
|
93
|
+
const targetKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${durableDrafts ? "durable" : "disabled"}`;
|
|
73
94
|
const [value, setValue] = useState("");
|
|
95
|
+
const [stateTargetKey, setStateTargetKey] = useState(targetKey);
|
|
74
96
|
const [sending, setSending] = useState(false);
|
|
75
97
|
const [pausing, setPausing] = useState(false);
|
|
76
98
|
const [resuming, setResuming] = useState(false);
|
|
77
99
|
const [error, setError] = useState<Error | null>(null);
|
|
78
100
|
const [draft, setDraft] = useState<ComposerDraft | null>(null);
|
|
79
|
-
const [draftLoading, setDraftLoading] = useState(Boolean(sessionId));
|
|
101
|
+
const [draftLoading, setDraftLoading] = useState(Boolean(sessionId) && durableDrafts);
|
|
80
102
|
const [draftSaving, setDraftSaving] = useState(false);
|
|
81
103
|
const [draftConflict, setDraftConflict] = useState<Error | null>(null);
|
|
82
104
|
const [restoredResources, setRestoredResources] = useState<ResourceRef[]>([]);
|
|
83
105
|
const pendingClientEventId = useRef<string | null>(null);
|
|
84
106
|
const draftRef = useRef<ComposerDraft | null>(null);
|
|
107
|
+
const draftLoadErrorRef = useRef<Error | null>(null);
|
|
108
|
+
const draftReadGeneration = useRef(0);
|
|
85
109
|
const localEditRevision = useRef(0);
|
|
86
110
|
const targetGeneration = useRef(0);
|
|
87
111
|
const lastSavedSignature = useRef<string | null>(null);
|
|
@@ -91,31 +115,57 @@ export function useComposer(
|
|
|
91
115
|
// Read through a ref so a new extras closure (created every render by
|
|
92
116
|
// callers passing inline functions) does not invalidate `send`.
|
|
93
117
|
const sendExtrasRef = useRef(options.sendExtras);
|
|
94
|
-
|
|
95
|
-
|
|
118
|
+
useLayoutEffect(() => {
|
|
119
|
+
sendExtrasRef.current = options.sendExtras;
|
|
120
|
+
}, [options.sendExtras]);
|
|
121
|
+
const liveExtras = resolveSendExtras(options.sendExtras);
|
|
122
|
+
const liveExtrasVersion = JSON.stringify(liveExtras);
|
|
96
123
|
|
|
97
124
|
// A composer is bound to one session: switching targets must not leak the
|
|
98
|
-
// previous session's draft, error, or retry idempotency key.
|
|
99
|
-
|
|
125
|
+
// previous session's draft, error, or retry idempotency key. Revoke the old
|
|
126
|
+
// target only when the new render commits: a suspended transition must leave
|
|
127
|
+
// the still-visible composer fully interactive.
|
|
100
128
|
const targetKeyRef = useRef(targetKey);
|
|
101
|
-
|
|
102
|
-
if (targetKeyRef.current
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
129
|
+
useLayoutEffect(() => {
|
|
130
|
+
if (targetKeyRef.current === targetKey) return;
|
|
131
|
+
targetKeyRef.current = targetKey;
|
|
132
|
+
targetGeneration.current += 1;
|
|
133
|
+
pendingClientEventId.current = null;
|
|
134
|
+
localEditRevision.current = 0;
|
|
135
|
+
draftReadGeneration.current += 1;
|
|
136
|
+
draftRef.current = null;
|
|
137
|
+
draftLoadErrorRef.current = null;
|
|
138
|
+
lastSavedSignature.current = null;
|
|
139
|
+
// An unresolved save for the old target must neither block nor join the
|
|
140
|
+
// new target's autosave chain. Its own generation fence drops settlement.
|
|
141
|
+
saveChain.current = Promise.resolve();
|
|
142
|
+
setStateTargetKey(targetKey);
|
|
143
|
+
setValue("");
|
|
144
|
+
setSending(false);
|
|
145
|
+
setPausing(false);
|
|
146
|
+
setResuming(false);
|
|
147
|
+
setError(null);
|
|
148
|
+
setDraft(null);
|
|
149
|
+
setDraftLoading(Boolean(sessionId) && durableDrafts);
|
|
150
|
+
setDraftSaving(false);
|
|
151
|
+
setDraftConflict(null);
|
|
152
|
+
setRestoredResources([]);
|
|
153
|
+
}, [durableDrafts, sessionId, targetKey]);
|
|
116
154
|
|
|
117
155
|
const applyDraft = useCallback(
|
|
118
156
|
(next: ComposerDraft): void => {
|
|
157
|
+
if (targetKeyRef.current !== targetKey) return;
|
|
158
|
+
if (!durableDrafts) {
|
|
159
|
+
pendingClientEventId.current = null;
|
|
160
|
+
localEditRevision.current += 1;
|
|
161
|
+
draftRef.current = null;
|
|
162
|
+
lastSavedSignature.current = null;
|
|
163
|
+
setDraft(null);
|
|
164
|
+
setValue(next.text);
|
|
165
|
+
setRestoredResources(next.resources);
|
|
166
|
+
setDraftConflict(null);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
119
169
|
draftRef.current = next;
|
|
120
170
|
lastSavedSignature.current = draftSignature(draftPayload(next));
|
|
121
171
|
localEditRevision.current += 1;
|
|
@@ -126,47 +176,84 @@ export function useComposer(
|
|
|
126
176
|
setDraftConflict(null);
|
|
127
177
|
onDraftApplied?.(next);
|
|
128
178
|
},
|
|
129
|
-
[onDraftApplied],
|
|
179
|
+
[durableDrafts, onDraftApplied, targetKey],
|
|
130
180
|
);
|
|
131
181
|
|
|
132
182
|
const loadDraft = useCallback(
|
|
133
|
-
async (replaceLocal: boolean): Promise<void> => {
|
|
134
|
-
if (
|
|
183
|
+
async (replaceLocal: boolean, rejectOnFailure = false): Promise<void> => {
|
|
184
|
+
if (targetKeyRef.current !== targetKey) return;
|
|
185
|
+
if (!sessionId || !durableDrafts) {
|
|
186
|
+
setDraftLoading(false);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
135
189
|
const generation = targetGeneration.current;
|
|
190
|
+
const readTicket = ++draftReadGeneration.current;
|
|
136
191
|
const localAtStart = localEditRevision.current;
|
|
137
192
|
setDraftLoading(true);
|
|
138
193
|
try {
|
|
139
194
|
const fetched = await client.getComposerDraft(workspaceId, sessionId);
|
|
140
|
-
if (generation !== targetGeneration.current)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
195
|
+
if (generation !== targetGeneration.current || targetKeyRef.current !== targetKey) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const ownsLatestRead = readTicket === draftReadGeneration.current;
|
|
199
|
+
const currentRevision = draftRef.current?.revision ?? -1;
|
|
200
|
+
if ((ownsLatestRead || rejectOnFailure) && fetched.revision >= currentRevision) {
|
|
201
|
+
draftRef.current = fetched;
|
|
202
|
+
setDraft(fetched);
|
|
203
|
+
setDraftConflict(null);
|
|
204
|
+
if (replaceLocal || localAtStart === localEditRevision.current) {
|
|
205
|
+
lastSavedSignature.current = draftSignature(draftPayload(fetched));
|
|
206
|
+
setValue(fetched.text);
|
|
207
|
+
setRestoredResources(fetched.resources);
|
|
208
|
+
onDraftApplied?.(fetched);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
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
|
+
}
|
|
149
221
|
}
|
|
150
222
|
} catch (cause) {
|
|
151
|
-
if (
|
|
223
|
+
if (
|
|
224
|
+
generation === targetGeneration.current &&
|
|
225
|
+
targetKeyRef.current === targetKey &&
|
|
226
|
+
(readTicket === draftReadGeneration.current || rejectOnFailure)
|
|
227
|
+
) {
|
|
228
|
+
const failure = asError(cause);
|
|
229
|
+
draftLoadErrorRef.current = failure;
|
|
230
|
+
setError(failure);
|
|
231
|
+
}
|
|
232
|
+
if (rejectOnFailure) throw cause;
|
|
152
233
|
} finally {
|
|
153
|
-
if (
|
|
234
|
+
if (
|
|
235
|
+
generation === targetGeneration.current &&
|
|
236
|
+
targetKeyRef.current === targetKey &&
|
|
237
|
+
readTicket === draftReadGeneration.current
|
|
238
|
+
) {
|
|
239
|
+
setDraftLoading(false);
|
|
240
|
+
}
|
|
154
241
|
}
|
|
155
242
|
},
|
|
156
|
-
[client, onDraftApplied, sessionId, workspaceId],
|
|
243
|
+
[client, durableDrafts, onDraftApplied, sessionId, targetKey, workspaceId],
|
|
157
244
|
);
|
|
158
245
|
|
|
159
246
|
useEffect(() => {
|
|
160
|
-
if (!sessionId) {
|
|
247
|
+
if (!sessionId || !durableDrafts) {
|
|
161
248
|
setDraftLoading(false);
|
|
162
249
|
return;
|
|
163
250
|
}
|
|
164
251
|
void loadDraft(false);
|
|
165
|
-
}, [loadDraft, sessionId]);
|
|
252
|
+
}, [durableDrafts, loadDraft, sessionId]);
|
|
166
253
|
useEffect(() => {
|
|
167
|
-
if (!sessionId) return;
|
|
254
|
+
if (!sessionId || !durableDrafts) return;
|
|
168
255
|
return registerSessionReconciler(sessionId, "composer", async () => await loadDraft(false));
|
|
169
|
-
}, [loadDraft, registerSessionReconciler, sessionId]);
|
|
256
|
+
}, [durableDrafts, loadDraft, registerSessionReconciler, sessionId]);
|
|
170
257
|
useSessionEventTrigger(
|
|
171
258
|
client,
|
|
172
259
|
workspaceId,
|
|
@@ -174,12 +261,14 @@ export function useComposer(
|
|
|
174
261
|
isComposerDraftEvent,
|
|
175
262
|
() => void loadDraft(false),
|
|
176
263
|
{
|
|
177
|
-
enabled: Boolean(sessionId),
|
|
264
|
+
enabled: Boolean(sessionId) && durableDrafts,
|
|
178
265
|
...(options.events !== undefined ? { events: options.events } : {}),
|
|
179
266
|
},
|
|
267
|
+
async () => await loadDraft(false, true),
|
|
180
268
|
);
|
|
181
269
|
|
|
182
270
|
const currentDraftPayload = useCallback((): SaveComposerDraftRequest | null => {
|
|
271
|
+
if (targetKeyRef.current !== targetKey) return null;
|
|
183
272
|
const base = draftRef.current;
|
|
184
273
|
if (!base) return null;
|
|
185
274
|
const extras = resolveSendExtras(sendExtrasRef.current);
|
|
@@ -191,13 +280,21 @@ export function useComposer(
|
|
|
191
280
|
model: extras.model ?? base.model,
|
|
192
281
|
reasoningEffort: extras.reasoningEffort ?? base.reasoningEffort,
|
|
193
282
|
};
|
|
194
|
-
}, [restoredResources, value]);
|
|
283
|
+
}, [restoredResources, targetKey, value]);
|
|
195
284
|
|
|
196
285
|
const persistPayload = useCallback(
|
|
197
286
|
async (payload: SaveComposerDraftRequest): Promise<boolean> => {
|
|
198
|
-
|
|
287
|
+
const ownedTargetKey = targetKey;
|
|
288
|
+
const ownedGeneration = targetGeneration.current;
|
|
289
|
+
if (!sessionId || targetKeyRef.current !== ownedTargetKey) return false;
|
|
199
290
|
let success = false;
|
|
200
291
|
const run = async () => {
|
|
292
|
+
if (
|
|
293
|
+
targetKeyRef.current !== ownedTargetKey ||
|
|
294
|
+
targetGeneration.current !== ownedGeneration
|
|
295
|
+
) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
201
298
|
const current = draftRef.current;
|
|
202
299
|
if (!current) return;
|
|
203
300
|
const request = { ...payload, expectedRevision: current.revision };
|
|
@@ -209,36 +306,61 @@ export function useComposer(
|
|
|
209
306
|
setDraftSaving(true);
|
|
210
307
|
try {
|
|
211
308
|
const saved = await client.saveComposerDraft(workspaceId, sessionId, request);
|
|
309
|
+
if (
|
|
310
|
+
targetKeyRef.current !== ownedTargetKey ||
|
|
311
|
+
targetGeneration.current !== ownedGeneration
|
|
312
|
+
) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
212
315
|
draftRef.current = saved;
|
|
213
316
|
setDraft(saved);
|
|
214
317
|
lastSavedSignature.current = signature;
|
|
215
318
|
setDraftConflict(null);
|
|
216
319
|
success = true;
|
|
217
320
|
} catch (cause) {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
321
|
+
if (
|
|
322
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
323
|
+
targetGeneration.current === ownedGeneration
|
|
324
|
+
) {
|
|
325
|
+
const problem = asError(cause);
|
|
326
|
+
setDraftConflict(problem);
|
|
327
|
+
setError(problem);
|
|
328
|
+
}
|
|
221
329
|
} finally {
|
|
222
|
-
|
|
330
|
+
if (
|
|
331
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
332
|
+
targetGeneration.current === ownedGeneration
|
|
333
|
+
) {
|
|
334
|
+
setDraftSaving(false);
|
|
335
|
+
}
|
|
223
336
|
}
|
|
224
337
|
};
|
|
225
338
|
saveChain.current = saveChain.current.then(run, run);
|
|
226
339
|
await saveChain.current;
|
|
227
340
|
return success;
|
|
228
341
|
},
|
|
229
|
-
[client, sessionId, workspaceId],
|
|
342
|
+
[client, sessionId, targetKey, workspaceId],
|
|
230
343
|
);
|
|
231
344
|
|
|
232
345
|
// Private durable autosave. A newer local edit is never replaced by an older
|
|
233
346
|
// response; saves serialize and each reads the latest acknowledged revision.
|
|
234
347
|
useEffect(() => {
|
|
235
|
-
if (
|
|
348
|
+
if (
|
|
349
|
+
!durableDrafts ||
|
|
350
|
+
!sessionId ||
|
|
351
|
+
draftLoading ||
|
|
352
|
+
sending ||
|
|
353
|
+
!draftRef.current ||
|
|
354
|
+
draftConflict
|
|
355
|
+
)
|
|
356
|
+
return;
|
|
236
357
|
const payload = currentDraftPayload();
|
|
237
358
|
if (!payload || draftSignature(payload) === lastSavedSignature.current) return;
|
|
238
359
|
const timer = window.setTimeout(() => void persistPayload(payload), 500);
|
|
239
360
|
return () => window.clearTimeout(timer);
|
|
240
361
|
}, [
|
|
241
362
|
currentDraftPayload,
|
|
363
|
+
durableDrafts,
|
|
242
364
|
draftConflict,
|
|
243
365
|
draftLoading,
|
|
244
366
|
liveExtrasVersion,
|
|
@@ -249,13 +371,21 @@ export function useComposer(
|
|
|
249
371
|
|
|
250
372
|
const dispatch = useCallback(
|
|
251
373
|
async (delivery: "send" | "steer", explicit?: string): Promise<boolean> => {
|
|
374
|
+
const ownedTargetKey = targetKey;
|
|
375
|
+
const ownedGeneration = targetGeneration.current;
|
|
252
376
|
const draftAtSend = value;
|
|
253
|
-
const
|
|
377
|
+
const rawText = explicit ?? draftAtSend;
|
|
378
|
+
const hasText = rawText.trim().length > 0;
|
|
254
379
|
// Resolve the extras once: a file-only message (empty text + ≥1 ready
|
|
255
380
|
// resource) is legitimate, so we must not bail on empty text alone.
|
|
256
381
|
const extras = resolveSendExtras(sendExtrasRef.current);
|
|
257
382
|
const hasResources = restoredResources.length > 0 || (extras.resources?.length ?? 0) > 0;
|
|
258
|
-
if (
|
|
383
|
+
if (
|
|
384
|
+
(!hasText && !hasResources) ||
|
|
385
|
+
!sessionId ||
|
|
386
|
+
sending ||
|
|
387
|
+
targetKeyRef.current !== ownedTargetKey
|
|
388
|
+
) {
|
|
259
389
|
return false;
|
|
260
390
|
}
|
|
261
391
|
// Reuse the clientEventId across retries of the same draft so a
|
|
@@ -264,12 +394,21 @@ export function useComposer(
|
|
|
264
394
|
setSending(true);
|
|
265
395
|
setError(null);
|
|
266
396
|
try {
|
|
267
|
-
|
|
397
|
+
// Trimming is only an emptiness check. A non-blank prompt must be sent
|
|
398
|
+
// byte-for-byte as the user wrote it, because expectedDraftRevision
|
|
399
|
+
// binds the submission to that exact durable draft. File-only messages
|
|
400
|
+
// use the same non-blank placeholder in both the saved draft and the
|
|
401
|
+
// submitted prompt so the content fence cannot reject its own client.
|
|
402
|
+
const sendText = hasText ? rawText : FILE_ONLY_MESSAGE_TEXT;
|
|
403
|
+
const currentPayload = currentDraftPayload();
|
|
404
|
+
const payload = currentPayload ? { ...currentPayload, text: sendText } : null;
|
|
268
405
|
if (payload && !(await persistPayload(payload))) return false;
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
406
|
+
if (
|
|
407
|
+
targetKeyRef.current !== ownedTargetKey ||
|
|
408
|
+
targetGeneration.current !== ownedGeneration
|
|
409
|
+
) {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
273
412
|
const input = composeSendInput(sendText, pendingClientEventId.current, extras, {
|
|
274
413
|
...(options.effectiveControl?.controlEtag
|
|
275
414
|
? { controlEtag: options.effectiveControl.controlEtag }
|
|
@@ -282,6 +421,12 @@ export function useComposer(
|
|
|
282
421
|
} else {
|
|
283
422
|
await client.sendMessage(workspaceId, sessionId, input);
|
|
284
423
|
}
|
|
424
|
+
if (
|
|
425
|
+
targetKeyRef.current !== ownedTargetKey ||
|
|
426
|
+
targetGeneration.current !== ownedGeneration
|
|
427
|
+
) {
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
285
430
|
pendingClientEventId.current = null;
|
|
286
431
|
const previousDraft = draftRef.current;
|
|
287
432
|
if (previousDraft) {
|
|
@@ -307,10 +452,20 @@ export function useComposer(
|
|
|
307
452
|
onSent?.(sendText);
|
|
308
453
|
return true;
|
|
309
454
|
} catch (cause) {
|
|
310
|
-
|
|
455
|
+
if (
|
|
456
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
457
|
+
targetGeneration.current === ownedGeneration
|
|
458
|
+
) {
|
|
459
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
460
|
+
}
|
|
311
461
|
return false;
|
|
312
462
|
} finally {
|
|
313
|
-
|
|
463
|
+
if (
|
|
464
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
465
|
+
targetGeneration.current === ownedGeneration
|
|
466
|
+
) {
|
|
467
|
+
setSending(false);
|
|
468
|
+
}
|
|
314
469
|
}
|
|
315
470
|
},
|
|
316
471
|
[
|
|
@@ -322,6 +477,7 @@ export function useComposer(
|
|
|
322
477
|
restoredResources,
|
|
323
478
|
sending,
|
|
324
479
|
sessionId,
|
|
480
|
+
targetKey,
|
|
325
481
|
value,
|
|
326
482
|
workspaceId,
|
|
327
483
|
],
|
|
@@ -335,13 +491,13 @@ export function useComposer(
|
|
|
335
491
|
// — keeping useComposer attachment-agnostic while still lighting up the send
|
|
336
492
|
// affordance the moment a file is ready. ChatComposer additionally gates this
|
|
337
493
|
// on its `attachments.uploading` flag so a message never departs mid-upload.
|
|
338
|
-
const hasReadyResources =
|
|
339
|
-
restoredResources.length > 0 ||
|
|
340
|
-
(resolveSendExtras(sendExtrasRef.current).resources?.length ?? 0) > 0;
|
|
494
|
+
const hasReadyResources = restoredResources.length > 0 || (liveExtras.resources?.length ?? 0) > 0;
|
|
341
495
|
|
|
342
496
|
const pause = useCallback(
|
|
343
497
|
async (reason?: string): Promise<void> => {
|
|
344
|
-
|
|
498
|
+
const ownedTargetKey = targetKey;
|
|
499
|
+
const ownedGeneration = targetGeneration.current;
|
|
500
|
+
if (!sessionId || pausing || targetKeyRef.current !== ownedTargetKey) {
|
|
345
501
|
return;
|
|
346
502
|
}
|
|
347
503
|
setPausing(true);
|
|
@@ -354,17 +510,29 @@ export function useComposer(
|
|
|
354
510
|
: {}),
|
|
355
511
|
});
|
|
356
512
|
} catch (cause) {
|
|
357
|
-
|
|
513
|
+
if (
|
|
514
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
515
|
+
targetGeneration.current === ownedGeneration
|
|
516
|
+
) {
|
|
517
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
518
|
+
}
|
|
358
519
|
} finally {
|
|
359
|
-
|
|
520
|
+
if (
|
|
521
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
522
|
+
targetGeneration.current === ownedGeneration
|
|
523
|
+
) {
|
|
524
|
+
setPausing(false);
|
|
525
|
+
}
|
|
360
526
|
}
|
|
361
527
|
},
|
|
362
|
-
[client, workspaceId, sessionId, pausing, options.effectiveControl?.controlEtag],
|
|
528
|
+
[client, workspaceId, sessionId, pausing, options.effectiveControl?.controlEtag, targetKey],
|
|
363
529
|
);
|
|
364
530
|
|
|
365
531
|
const resume = useCallback(
|
|
366
532
|
async (reason?: string): Promise<void> => {
|
|
367
|
-
|
|
533
|
+
const ownedTargetKey = targetKey;
|
|
534
|
+
const ownedGeneration = targetGeneration.current;
|
|
535
|
+
if (!sessionId || resuming || targetKeyRef.current !== ownedTargetKey) return;
|
|
368
536
|
setResuming(true);
|
|
369
537
|
setError(null);
|
|
370
538
|
try {
|
|
@@ -375,21 +543,36 @@ export function useComposer(
|
|
|
375
543
|
: {}),
|
|
376
544
|
});
|
|
377
545
|
} catch (cause) {
|
|
378
|
-
|
|
546
|
+
if (
|
|
547
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
548
|
+
targetGeneration.current === ownedGeneration
|
|
549
|
+
) {
|
|
550
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
551
|
+
}
|
|
379
552
|
} finally {
|
|
380
|
-
|
|
553
|
+
if (
|
|
554
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
555
|
+
targetGeneration.current === ownedGeneration
|
|
556
|
+
) {
|
|
557
|
+
setResuming(false);
|
|
558
|
+
}
|
|
381
559
|
}
|
|
382
560
|
},
|
|
383
|
-
[client, workspaceId, sessionId, resuming, options.effectiveControl?.controlEtag],
|
|
561
|
+
[client, workspaceId, sessionId, resuming, options.effectiveControl?.controlEtag, targetKey],
|
|
384
562
|
);
|
|
385
563
|
|
|
386
564
|
const resumeScope = useCallback(
|
|
387
565
|
async (option: EffectiveControlResumeOption): Promise<void> => {
|
|
388
|
-
|
|
566
|
+
const ownedTargetKey = targetKey;
|
|
567
|
+
const ownedGeneration = targetGeneration.current;
|
|
568
|
+
if (!sessionId || resuming || targetKeyRef.current !== ownedTargetKey) return;
|
|
389
569
|
setResuming(true);
|
|
390
570
|
setError(null);
|
|
391
571
|
try {
|
|
392
572
|
if (option.scope === "workspace") {
|
|
573
|
+
if (!client.setWorkspaceInferenceState) {
|
|
574
|
+
throw new Error("Workspace-level resume is not available through this client");
|
|
575
|
+
}
|
|
393
576
|
const workspaceBlocker = options.effectiveControl?.blockers.find(
|
|
394
577
|
(blocker) => blocker.kind === "workspace",
|
|
395
578
|
);
|
|
@@ -400,6 +583,12 @@ export function useComposer(
|
|
|
400
583
|
});
|
|
401
584
|
} else if (option.scope === "session" && option.targetId) {
|
|
402
585
|
const target = await client.getQueue(workspaceId, option.targetId);
|
|
586
|
+
if (
|
|
587
|
+
targetKeyRef.current !== ownedTargetKey ||
|
|
588
|
+
targetGeneration.current !== ownedGeneration
|
|
589
|
+
) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
403
592
|
await client.resumeSession(workspaceId, option.targetId, {
|
|
404
593
|
expectedControlEtag: target.effectiveControl.controlEtag,
|
|
405
594
|
});
|
|
@@ -411,29 +600,52 @@ export function useComposer(
|
|
|
411
600
|
});
|
|
412
601
|
}
|
|
413
602
|
} catch (cause) {
|
|
414
|
-
|
|
603
|
+
if (
|
|
604
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
605
|
+
targetGeneration.current === ownedGeneration
|
|
606
|
+
) {
|
|
607
|
+
setError(asError(cause));
|
|
608
|
+
}
|
|
415
609
|
} finally {
|
|
416
|
-
|
|
610
|
+
if (
|
|
611
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
612
|
+
targetGeneration.current === ownedGeneration
|
|
613
|
+
) {
|
|
614
|
+
setResuming(false);
|
|
615
|
+
}
|
|
417
616
|
}
|
|
418
617
|
},
|
|
419
|
-
[client, options.effectiveControl, resuming, sessionId, workspaceId],
|
|
618
|
+
[client, options.effectiveControl, resuming, sessionId, targetKey, workspaceId],
|
|
420
619
|
);
|
|
421
620
|
|
|
422
|
-
const updateValue = useCallback(
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
621
|
+
const updateValue = useCallback(
|
|
622
|
+
(next: string) => {
|
|
623
|
+
if (targetKeyRef.current !== targetKey) return;
|
|
624
|
+
pendingClientEventId.current = null;
|
|
625
|
+
localEditRevision.current += 1;
|
|
626
|
+
setValue(next);
|
|
627
|
+
},
|
|
628
|
+
[targetKey],
|
|
629
|
+
);
|
|
427
630
|
|
|
428
|
-
const removeRestoredResource = useCallback(
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
631
|
+
const removeRestoredResource = useCallback(
|
|
632
|
+
(index: number) => {
|
|
633
|
+
if (targetKeyRef.current !== targetKey) return;
|
|
634
|
+
localEditRevision.current += 1;
|
|
635
|
+
setRestoredResources((current) => current.filter((_, candidate) => candidate !== index));
|
|
636
|
+
},
|
|
637
|
+
[targetKey],
|
|
638
|
+
);
|
|
432
639
|
|
|
433
640
|
const resolveDraftConflict = useCallback(
|
|
434
641
|
async (choice: "keep_mine" | "use_remote"): Promise<void> => {
|
|
435
|
-
|
|
642
|
+
const ownedTargetKey = targetKey;
|
|
643
|
+
const ownedGeneration = targetGeneration.current;
|
|
644
|
+
if (!sessionId || !durableDrafts || targetKeyRef.current !== ownedTargetKey) return;
|
|
436
645
|
const remote = await client.getComposerDraft(workspaceId, sessionId);
|
|
646
|
+
if (targetKeyRef.current !== ownedTargetKey || targetGeneration.current !== ownedGeneration) {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
437
649
|
if (choice === "use_remote") {
|
|
438
650
|
applyDraft(remote);
|
|
439
651
|
return;
|
|
@@ -444,36 +656,55 @@ export function useComposer(
|
|
|
444
656
|
const payload = currentDraftPayload();
|
|
445
657
|
if (payload) await persistPayload({ ...payload, expectedRevision: remote.revision });
|
|
446
658
|
},
|
|
447
|
-
[
|
|
659
|
+
[
|
|
660
|
+
applyDraft,
|
|
661
|
+
client,
|
|
662
|
+
currentDraftPayload,
|
|
663
|
+
durableDrafts,
|
|
664
|
+
persistPayload,
|
|
665
|
+
sessionId,
|
|
666
|
+
targetKey,
|
|
667
|
+
workspaceId,
|
|
668
|
+
],
|
|
448
669
|
);
|
|
449
670
|
|
|
671
|
+
const identityMatches = stateTargetKey === targetKey;
|
|
672
|
+
const reloadDraft = useCallback(async () => await loadDraft(true), [loadDraft]);
|
|
673
|
+
const clearError = useCallback(() => {
|
|
674
|
+
if (targetKeyRef.current !== targetKey) return;
|
|
675
|
+
setError(null);
|
|
676
|
+
setDraftConflict(null);
|
|
677
|
+
}, [targetKey]);
|
|
678
|
+
|
|
450
679
|
return {
|
|
451
|
-
value,
|
|
680
|
+
value: identityMatches ? value : "",
|
|
452
681
|
setValue: updateValue,
|
|
453
682
|
send,
|
|
454
683
|
steer,
|
|
455
|
-
sending,
|
|
456
|
-
canSend:
|
|
684
|
+
sending: identityMatches ? sending : false,
|
|
685
|
+
canSend:
|
|
686
|
+
identityMatches &&
|
|
687
|
+
Boolean(sessionId) &&
|
|
688
|
+
!sending &&
|
|
689
|
+
(value.trim().length > 0 || hasReadyResources),
|
|
457
690
|
pause,
|
|
458
|
-
pausing,
|
|
691
|
+
pausing: identityMatches ? pausing : false,
|
|
459
692
|
resume,
|
|
460
693
|
resumeScope,
|
|
461
|
-
resuming,
|
|
462
|
-
draft,
|
|
463
|
-
draftRevision: draft?.revision ?? 0,
|
|
464
|
-
draftLoading,
|
|
465
|
-
draftSaving,
|
|
466
|
-
draftConflict,
|
|
694
|
+
resuming: identityMatches ? resuming : false,
|
|
695
|
+
draft: identityMatches ? draft : null,
|
|
696
|
+
draftRevision: identityMatches ? (draft?.revision ?? 0) : 0,
|
|
697
|
+
draftLoading: identityMatches ? draftLoading : Boolean(sessionId) && durableDrafts,
|
|
698
|
+
draftSaving: identityMatches ? draftSaving : false,
|
|
699
|
+
draftConflict: identityMatches ? draftConflict : null,
|
|
700
|
+
draftPersistence: durableDrafts ? "durable" : "disabled",
|
|
467
701
|
applyDraft,
|
|
468
|
-
reloadDraft
|
|
702
|
+
reloadDraft,
|
|
469
703
|
resolveDraftConflict,
|
|
470
|
-
restoredResources,
|
|
704
|
+
restoredResources: identityMatches ? restoredResources : [],
|
|
471
705
|
removeRestoredResource,
|
|
472
|
-
error,
|
|
473
|
-
clearError
|
|
474
|
-
setError(null);
|
|
475
|
-
setDraftConflict(null);
|
|
476
|
-
}, []),
|
|
706
|
+
error: identityMatches ? error : null,
|
|
707
|
+
clearError,
|
|
477
708
|
};
|
|
478
709
|
}
|
|
479
710
|
|