@opengeni/react 0.13.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 +129 -23
- package/dist/chunk-6BXBGJ3B.js +1956 -0
- 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 +642 -1080
- package/dist/index.js +7672 -8005
- package/dist/index.js.map +1 -1
- package/dist/machines.d.ts +412 -3
- package/dist/machines.js +25 -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 +23 -4
- package/src/approvals.ts +5 -3
- package/src/client.ts +50 -1
- package/src/components/approval-surface.tsx +198 -0
- package/src/components/chat-composer.tsx +104 -751
- 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 +82 -16
- package/src/components/machine-health-pill.tsx +68 -0
- package/src/components/machine-metrics.tsx +10 -24
- package/src/components/machines/health.ts +146 -0
- package/src/components/machines/machine-detail.tsx +220 -0
- package/src/components/machines/metric-history-chart.tsx +298 -0
- package/src/components/machines/metric-sparkline.tsx +76 -0
- package/src/components/machines/series.ts +113 -0
- package/src/components/machines-dashboard.tsx +16 -4
- 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 +60 -0
- package/src/components/sandbox-files.tsx +230 -21
- package/src/components/sandbox-terminal.tsx +8 -2
- package/src/components/sandbox-workspace.tsx +489 -135
- package/src/components/session-status.tsx +0 -6
- package/src/components/workbench-changes.tsx +145 -50
- package/src/components/workspace-dock.tsx +329 -68
- package/src/composer.ts +60 -0
- package/src/hooks/internal.ts +115 -34
- package/src/hooks/use-composer.ts +635 -74
- 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 +43 -15
- package/src/hooks/use-session-events.ts +523 -48
- package/src/hooks/use-session-lineage.ts +15 -6
- package/src/hooks/use-session.ts +10 -2
- package/src/hooks/use-slash-commands.ts +38 -38
- package/src/hooks/use-transcription.ts +757 -0
- package/src/hooks/use-turn-queue.ts +273 -77
- 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 +88 -12
- 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/machines.ts +16 -0
- package/src/provider.tsx +163 -41
- package/src/session-context.ts +135 -0
- package/src/session.ts +79 -0
- package/src/timeline/parsers.ts +78 -6
- package/src/timeline/projection.ts +36 -6
- 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 +78 -0
- package/styles/index.d.ts +2 -0
- package/styles/tokens.css +11 -6
- package/styles/tokens.d.ts +2 -0
- package/dist/chunk-NFYVQWIB.js +0 -1377
- package/dist/chunk-NFYVQWIB.js.map +0 -1
- package/dist/machines-CnlMb7E-.d.ts +0 -329
|
@@ -1,38 +1,79 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import type {
|
|
2
|
+
ComposerDraft,
|
|
3
|
+
EffectiveControlResumeOption,
|
|
4
|
+
EffectiveSessionControl,
|
|
5
|
+
ResourceRef,
|
|
6
|
+
SaveComposerDraftRequest,
|
|
7
|
+
SendMessageInput,
|
|
8
|
+
SessionEvent,
|
|
9
|
+
} from "@opengeni/sdk";
|
|
10
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
11
|
+
import { useEmbeddedSession, type EmbeddedSessionClientOverride } from "../session-context";
|
|
12
|
+
import { useSessionEventTrigger, type SessionEventFeedOptions } from "./internal";
|
|
4
13
|
|
|
5
14
|
export type ComposerSendExtras = Omit<SendMessageInput, "text" | "clientEventId">;
|
|
6
15
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
export type UseComposerOptions = EmbeddedSessionClientOverride &
|
|
17
|
+
SessionEventFeedOptions & {
|
|
18
|
+
/** Called with the accepted text after a successful send. */
|
|
19
|
+
onSent?: ((text: string) => void) | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Extra message fields (resources, tools, model, reasoningEffort) merged
|
|
22
|
+
* into every send. A function is evaluated at send time so it can read the
|
|
23
|
+
* surrounding UI state (attachment pickers, model selectors, ...).
|
|
24
|
+
*/
|
|
25
|
+
sendExtras?: ComposerSendExtras | (() => ComposerSendExtras) | undefined;
|
|
26
|
+
/** Latest server-derived workstream control; bound into Send/Steer OCC. */
|
|
27
|
+
effectiveControl?: EffectiveSessionControl | null | undefined;
|
|
28
|
+
/** Apply durable model/tool/reasoning settings in the host's controlled UI. */
|
|
29
|
+
onDraftApplied?: ((draft: ComposerDraft) => void) | undefined;
|
|
30
|
+
/**
|
|
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;
|
|
42
|
+
};
|
|
23
43
|
|
|
24
44
|
export type ComposerState = {
|
|
25
45
|
value: string;
|
|
26
46
|
setValue: (value: string) => void;
|
|
27
|
-
/**
|
|
28
|
-
send: (text?: string
|
|
47
|
+
/** Append the draft behind prompts already visible in the queue. */
|
|
48
|
+
send: (text?: string) => Promise<boolean>;
|
|
49
|
+
/** Supersede current direction with the draft. */
|
|
50
|
+
steer: (text?: string) => Promise<boolean>;
|
|
29
51
|
sending: boolean;
|
|
30
52
|
canSend: boolean;
|
|
31
53
|
/** Pause the session without deleting its prompt queue. */
|
|
32
54
|
pause: (reason?: string) => Promise<void>;
|
|
33
55
|
pausing: boolean;
|
|
34
56
|
resume: (reason?: string) => Promise<void>;
|
|
57
|
+
resumeScope: (option: EffectiveControlResumeOption) => Promise<void>;
|
|
35
58
|
resuming: boolean;
|
|
59
|
+
draft: ComposerDraft | null;
|
|
60
|
+
draftRevision: number;
|
|
61
|
+
draftLoading: boolean;
|
|
62
|
+
draftSaving: boolean;
|
|
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;
|
|
71
|
+
/** Apply an atomic queue Edit checkout without a second read. */
|
|
72
|
+
applyDraft: (draft: ComposerDraft) => void;
|
|
73
|
+
reloadDraft: () => Promise<void>;
|
|
74
|
+
resolveDraftConflict: (choice: "keep_mine" | "use_remote") => Promise<void>;
|
|
75
|
+
restoredResources: ResourceRef[];
|
|
76
|
+
removeRestoredResource: (index: number) => void;
|
|
36
77
|
error: Error | null;
|
|
37
78
|
clearError: () => void;
|
|
38
79
|
};
|
|
@@ -47,41 +88,304 @@ export function useComposer(
|
|
|
47
88
|
sessionId: string | null | undefined,
|
|
48
89
|
options: UseComposerOptions = {},
|
|
49
90
|
): ComposerState {
|
|
50
|
-
const { client, workspaceId } =
|
|
91
|
+
const { client, workspaceId, registerSessionReconciler } = useEmbeddedSession(options);
|
|
92
|
+
const durableDrafts = options.draftPersistence !== "disabled";
|
|
93
|
+
const targetKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${durableDrafts ? "durable" : "disabled"}`;
|
|
51
94
|
const [value, setValue] = useState("");
|
|
95
|
+
const [stateTargetKey, setStateTargetKey] = useState(targetKey);
|
|
52
96
|
const [sending, setSending] = useState(false);
|
|
53
97
|
const [pausing, setPausing] = useState(false);
|
|
54
98
|
const [resuming, setResuming] = useState(false);
|
|
55
99
|
const [error, setError] = useState<Error | null>(null);
|
|
100
|
+
const [draft, setDraft] = useState<ComposerDraft | null>(null);
|
|
101
|
+
const [draftLoading, setDraftLoading] = useState(Boolean(sessionId) && durableDrafts);
|
|
102
|
+
const [draftSaving, setDraftSaving] = useState(false);
|
|
103
|
+
const [draftConflict, setDraftConflict] = useState<Error | null>(null);
|
|
104
|
+
const [restoredResources, setRestoredResources] = useState<ResourceRef[]>([]);
|
|
56
105
|
const pendingClientEventId = useRef<string | null>(null);
|
|
106
|
+
const draftRef = useRef<ComposerDraft | null>(null);
|
|
107
|
+
const draftLoadErrorRef = useRef<Error | null>(null);
|
|
108
|
+
const draftReadGeneration = useRef(0);
|
|
109
|
+
const localEditRevision = useRef(0);
|
|
110
|
+
const targetGeneration = useRef(0);
|
|
111
|
+
const lastSavedSignature = useRef<string | null>(null);
|
|
112
|
+
const saveChain = useRef<Promise<void>>(Promise.resolve());
|
|
57
113
|
const onSent = options.onSent;
|
|
114
|
+
const onDraftApplied = options.onDraftApplied;
|
|
58
115
|
// Read through a ref so a new extras closure (created every render by
|
|
59
116
|
// callers passing inline functions) does not invalidate `send`.
|
|
60
117
|
const sendExtrasRef = useRef(options.sendExtras);
|
|
61
|
-
|
|
118
|
+
useLayoutEffect(() => {
|
|
119
|
+
sendExtrasRef.current = options.sendExtras;
|
|
120
|
+
}, [options.sendExtras]);
|
|
121
|
+
const liveExtras = resolveSendExtras(options.sendExtras);
|
|
122
|
+
const liveExtrasVersion = JSON.stringify(liveExtras);
|
|
62
123
|
|
|
63
124
|
// A composer is bound to one session: switching targets must not leak the
|
|
64
|
-
// previous session's draft, error, or retry idempotency key.
|
|
65
|
-
|
|
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.
|
|
66
128
|
const targetKeyRef = useRef(targetKey);
|
|
67
|
-
|
|
68
|
-
if (targetKeyRef.current
|
|
69
|
-
|
|
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]);
|
|
154
|
+
|
|
155
|
+
const applyDraft = useCallback(
|
|
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
|
+
}
|
|
169
|
+
draftRef.current = next;
|
|
170
|
+
lastSavedSignature.current = draftSignature(draftPayload(next));
|
|
171
|
+
localEditRevision.current += 1;
|
|
70
172
|
pendingClientEventId.current = null;
|
|
71
|
-
|
|
72
|
-
|
|
173
|
+
setDraft(next);
|
|
174
|
+
setValue(next.text);
|
|
175
|
+
setRestoredResources(next.resources);
|
|
176
|
+
setDraftConflict(null);
|
|
177
|
+
onDraftApplied?.(next);
|
|
178
|
+
},
|
|
179
|
+
[durableDrafts, onDraftApplied, targetKey],
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const loadDraft = useCallback(
|
|
183
|
+
async (replaceLocal: boolean, rejectOnFailure = false): Promise<void> => {
|
|
184
|
+
if (targetKeyRef.current !== targetKey) return;
|
|
185
|
+
if (!sessionId || !durableDrafts) {
|
|
186
|
+
setDraftLoading(false);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const generation = targetGeneration.current;
|
|
190
|
+
const readTicket = ++draftReadGeneration.current;
|
|
191
|
+
const localAtStart = localEditRevision.current;
|
|
192
|
+
setDraftLoading(true);
|
|
193
|
+
try {
|
|
194
|
+
const fetched = await client.getComposerDraft(workspaceId, sessionId);
|
|
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
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} catch (cause) {
|
|
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;
|
|
233
|
+
} finally {
|
|
234
|
+
if (
|
|
235
|
+
generation === targetGeneration.current &&
|
|
236
|
+
targetKeyRef.current === targetKey &&
|
|
237
|
+
readTicket === draftReadGeneration.current
|
|
238
|
+
) {
|
|
239
|
+
setDraftLoading(false);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
[client, durableDrafts, onDraftApplied, sessionId, targetKey, workspaceId],
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
if (!sessionId || !durableDrafts) {
|
|
248
|
+
setDraftLoading(false);
|
|
249
|
+
return;
|
|
73
250
|
}
|
|
74
|
-
|
|
251
|
+
void loadDraft(false);
|
|
252
|
+
}, [durableDrafts, loadDraft, sessionId]);
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (!sessionId || !durableDrafts) return;
|
|
255
|
+
return registerSessionReconciler(sessionId, "composer", async () => await loadDraft(false));
|
|
256
|
+
}, [durableDrafts, loadDraft, registerSessionReconciler, sessionId]);
|
|
257
|
+
useSessionEventTrigger(
|
|
258
|
+
client,
|
|
259
|
+
workspaceId,
|
|
260
|
+
sessionId,
|
|
261
|
+
isComposerDraftEvent,
|
|
262
|
+
() => void loadDraft(false),
|
|
263
|
+
{
|
|
264
|
+
enabled: Boolean(sessionId) && durableDrafts,
|
|
265
|
+
...(options.events !== undefined ? { events: options.events } : {}),
|
|
266
|
+
},
|
|
267
|
+
async () => await loadDraft(false, true),
|
|
268
|
+
);
|
|
75
269
|
|
|
76
|
-
const
|
|
77
|
-
|
|
270
|
+
const currentDraftPayload = useCallback((): SaveComposerDraftRequest | null => {
|
|
271
|
+
if (targetKeyRef.current !== targetKey) return null;
|
|
272
|
+
const base = draftRef.current;
|
|
273
|
+
if (!base) return null;
|
|
274
|
+
const extras = resolveSendExtras(sendExtrasRef.current);
|
|
275
|
+
return {
|
|
276
|
+
expectedRevision: base.revision,
|
|
277
|
+
text: value,
|
|
278
|
+
resources: mergeResources(restoredResources, extras.resources ?? []),
|
|
279
|
+
tools: extras.tools ?? base.tools,
|
|
280
|
+
model: extras.model ?? base.model,
|
|
281
|
+
reasoningEffort: extras.reasoningEffort ?? base.reasoningEffort,
|
|
282
|
+
};
|
|
283
|
+
}, [restoredResources, targetKey, value]);
|
|
284
|
+
|
|
285
|
+
const persistPayload = useCallback(
|
|
286
|
+
async (payload: SaveComposerDraftRequest): Promise<boolean> => {
|
|
287
|
+
const ownedTargetKey = targetKey;
|
|
288
|
+
const ownedGeneration = targetGeneration.current;
|
|
289
|
+
if (!sessionId || targetKeyRef.current !== ownedTargetKey) return false;
|
|
290
|
+
let success = false;
|
|
291
|
+
const run = async () => {
|
|
292
|
+
if (
|
|
293
|
+
targetKeyRef.current !== ownedTargetKey ||
|
|
294
|
+
targetGeneration.current !== ownedGeneration
|
|
295
|
+
) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const current = draftRef.current;
|
|
299
|
+
if (!current) return;
|
|
300
|
+
const request = { ...payload, expectedRevision: current.revision };
|
|
301
|
+
const signature = draftSignature(request);
|
|
302
|
+
if (signature === lastSavedSignature.current) {
|
|
303
|
+
success = true;
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
setDraftSaving(true);
|
|
307
|
+
try {
|
|
308
|
+
const saved = await client.saveComposerDraft(workspaceId, sessionId, request);
|
|
309
|
+
if (
|
|
310
|
+
targetKeyRef.current !== ownedTargetKey ||
|
|
311
|
+
targetGeneration.current !== ownedGeneration
|
|
312
|
+
) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
draftRef.current = saved;
|
|
316
|
+
setDraft(saved);
|
|
317
|
+
lastSavedSignature.current = signature;
|
|
318
|
+
setDraftConflict(null);
|
|
319
|
+
success = true;
|
|
320
|
+
} catch (cause) {
|
|
321
|
+
if (
|
|
322
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
323
|
+
targetGeneration.current === ownedGeneration
|
|
324
|
+
) {
|
|
325
|
+
const problem = asError(cause);
|
|
326
|
+
setDraftConflict(problem);
|
|
327
|
+
setError(problem);
|
|
328
|
+
}
|
|
329
|
+
} finally {
|
|
330
|
+
if (
|
|
331
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
332
|
+
targetGeneration.current === ownedGeneration
|
|
333
|
+
) {
|
|
334
|
+
setDraftSaving(false);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
saveChain.current = saveChain.current.then(run, run);
|
|
339
|
+
await saveChain.current;
|
|
340
|
+
return success;
|
|
341
|
+
},
|
|
342
|
+
[client, sessionId, targetKey, workspaceId],
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
// Private durable autosave. A newer local edit is never replaced by an older
|
|
346
|
+
// response; saves serialize and each reads the latest acknowledged revision.
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
if (
|
|
349
|
+
!durableDrafts ||
|
|
350
|
+
!sessionId ||
|
|
351
|
+
draftLoading ||
|
|
352
|
+
sending ||
|
|
353
|
+
!draftRef.current ||
|
|
354
|
+
draftConflict
|
|
355
|
+
)
|
|
356
|
+
return;
|
|
357
|
+
const payload = currentDraftPayload();
|
|
358
|
+
if (!payload || draftSignature(payload) === lastSavedSignature.current) return;
|
|
359
|
+
const timer = window.setTimeout(() => void persistPayload(payload), 500);
|
|
360
|
+
return () => window.clearTimeout(timer);
|
|
361
|
+
}, [
|
|
362
|
+
currentDraftPayload,
|
|
363
|
+
durableDrafts,
|
|
364
|
+
draftConflict,
|
|
365
|
+
draftLoading,
|
|
366
|
+
liveExtrasVersion,
|
|
367
|
+
persistPayload,
|
|
368
|
+
sending,
|
|
369
|
+
sessionId,
|
|
370
|
+
]);
|
|
371
|
+
|
|
372
|
+
const dispatch = useCallback(
|
|
373
|
+
async (delivery: "send" | "steer", explicit?: string): Promise<boolean> => {
|
|
374
|
+
const ownedTargetKey = targetKey;
|
|
375
|
+
const ownedGeneration = targetGeneration.current;
|
|
78
376
|
const draftAtSend = value;
|
|
79
|
-
const
|
|
377
|
+
const rawText = explicit ?? draftAtSend;
|
|
378
|
+
const hasText = rawText.trim().length > 0;
|
|
80
379
|
// Resolve the extras once: a file-only message (empty text + ≥1 ready
|
|
81
380
|
// resource) is legitimate, so we must not bail on empty text alone.
|
|
82
381
|
const extras = resolveSendExtras(sendExtrasRef.current);
|
|
83
|
-
const hasResources = (extras.resources?.length ?? 0) > 0;
|
|
84
|
-
if (
|
|
382
|
+
const hasResources = restoredResources.length > 0 || (extras.resources?.length ?? 0) > 0;
|
|
383
|
+
if (
|
|
384
|
+
(!hasText && !hasResources) ||
|
|
385
|
+
!sessionId ||
|
|
386
|
+
sending ||
|
|
387
|
+
targetKeyRef.current !== ownedTargetKey
|
|
388
|
+
) {
|
|
85
389
|
return false;
|
|
86
390
|
}
|
|
87
391
|
// Reuse the clientEventId across retries of the same draft so a
|
|
@@ -90,17 +394,56 @@ export function useComposer(
|
|
|
90
394
|
setSending(true);
|
|
91
395
|
setError(null);
|
|
92
396
|
try {
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
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;
|
|
405
|
+
if (payload && !(await persistPayload(payload))) return false;
|
|
406
|
+
if (
|
|
407
|
+
targetKeyRef.current !== ownedTargetKey ||
|
|
408
|
+
targetGeneration.current !== ownedGeneration
|
|
409
|
+
) {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
const input = composeSendInput(sendText, pendingClientEventId.current, extras, {
|
|
413
|
+
...(options.effectiveControl?.controlEtag
|
|
414
|
+
? { controlEtag: options.effectiveControl.controlEtag }
|
|
415
|
+
: {}),
|
|
416
|
+
...(draftRef.current ? { expectedDraftRevision: draftRef.current.revision } : {}),
|
|
417
|
+
resources: mergeResources(restoredResources, extras.resources ?? []),
|
|
418
|
+
});
|
|
98
419
|
if (delivery === "steer") {
|
|
99
420
|
await client.steerMessage(workspaceId, sessionId, input);
|
|
100
421
|
} else {
|
|
101
422
|
await client.sendMessage(workspaceId, sessionId, input);
|
|
102
423
|
}
|
|
424
|
+
if (
|
|
425
|
+
targetKeyRef.current !== ownedTargetKey ||
|
|
426
|
+
targetGeneration.current !== ownedGeneration
|
|
427
|
+
) {
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
103
430
|
pendingClientEventId.current = null;
|
|
431
|
+
const previousDraft = draftRef.current;
|
|
432
|
+
if (previousDraft) {
|
|
433
|
+
const cleared = {
|
|
434
|
+
...previousDraft,
|
|
435
|
+
revision: 0,
|
|
436
|
+
text: "",
|
|
437
|
+
resources: [],
|
|
438
|
+
sourceTurnId: null,
|
|
439
|
+
sourceTurnVersion: null,
|
|
440
|
+
updatedAt: null,
|
|
441
|
+
};
|
|
442
|
+
draftRef.current = cleared;
|
|
443
|
+
setDraft(cleared);
|
|
444
|
+
setRestoredResources([]);
|
|
445
|
+
lastSavedSignature.current = draftSignature(draftPayload(cleared));
|
|
446
|
+
}
|
|
104
447
|
if (explicit === undefined) {
|
|
105
448
|
// Clear only the draft that was sent: edits made while the request
|
|
106
449
|
// was in flight were never delivered and must survive.
|
|
@@ -109,76 +452,267 @@ export function useComposer(
|
|
|
109
452
|
onSent?.(sendText);
|
|
110
453
|
return true;
|
|
111
454
|
} catch (cause) {
|
|
112
|
-
|
|
455
|
+
if (
|
|
456
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
457
|
+
targetGeneration.current === ownedGeneration
|
|
458
|
+
) {
|
|
459
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
460
|
+
}
|
|
113
461
|
return false;
|
|
114
462
|
} finally {
|
|
115
|
-
|
|
463
|
+
if (
|
|
464
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
465
|
+
targetGeneration.current === ownedGeneration
|
|
466
|
+
) {
|
|
467
|
+
setSending(false);
|
|
468
|
+
}
|
|
116
469
|
}
|
|
117
470
|
},
|
|
118
|
-
[
|
|
471
|
+
[
|
|
472
|
+
client,
|
|
473
|
+
currentDraftPayload,
|
|
474
|
+
onSent,
|
|
475
|
+
options.effectiveControl?.controlEtag,
|
|
476
|
+
persistPayload,
|
|
477
|
+
restoredResources,
|
|
478
|
+
sending,
|
|
479
|
+
sessionId,
|
|
480
|
+
targetKey,
|
|
481
|
+
value,
|
|
482
|
+
workspaceId,
|
|
483
|
+
],
|
|
119
484
|
);
|
|
120
485
|
|
|
486
|
+
const send = useCallback(async (text?: string) => await dispatch("send", text), [dispatch]);
|
|
487
|
+
const steer = useCallback(async (text?: string) => await dispatch("steer", text), [dispatch]);
|
|
488
|
+
|
|
121
489
|
// A send is possible with non-empty text OR with ≥1 attached resource (a
|
|
122
490
|
// file-only message). Resources ride in `sendExtras`, so we resolve them here
|
|
123
491
|
// — keeping useComposer attachment-agnostic while still lighting up the send
|
|
124
492
|
// affordance the moment a file is ready. ChatComposer additionally gates this
|
|
125
493
|
// on its `attachments.uploading` flag so a message never departs mid-upload.
|
|
126
|
-
const hasReadyResources = (
|
|
494
|
+
const hasReadyResources = restoredResources.length > 0 || (liveExtras.resources?.length ?? 0) > 0;
|
|
127
495
|
|
|
128
496
|
const pause = useCallback(
|
|
129
497
|
async (reason?: string): Promise<void> => {
|
|
130
|
-
|
|
498
|
+
const ownedTargetKey = targetKey;
|
|
499
|
+
const ownedGeneration = targetGeneration.current;
|
|
500
|
+
if (!sessionId || pausing || targetKeyRef.current !== ownedTargetKey) {
|
|
131
501
|
return;
|
|
132
502
|
}
|
|
133
503
|
setPausing(true);
|
|
134
504
|
setError(null);
|
|
135
505
|
try {
|
|
136
|
-
await client.pauseSession(workspaceId, sessionId,
|
|
506
|
+
await client.pauseSession(workspaceId, sessionId, {
|
|
507
|
+
...(reason !== undefined ? { reason } : {}),
|
|
508
|
+
...(options.effectiveControl?.controlEtag
|
|
509
|
+
? { expectedControlEtag: options.effectiveControl.controlEtag }
|
|
510
|
+
: {}),
|
|
511
|
+
});
|
|
137
512
|
} catch (cause) {
|
|
138
|
-
|
|
513
|
+
if (
|
|
514
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
515
|
+
targetGeneration.current === ownedGeneration
|
|
516
|
+
) {
|
|
517
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
518
|
+
}
|
|
139
519
|
} finally {
|
|
140
|
-
|
|
520
|
+
if (
|
|
521
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
522
|
+
targetGeneration.current === ownedGeneration
|
|
523
|
+
) {
|
|
524
|
+
setPausing(false);
|
|
525
|
+
}
|
|
141
526
|
}
|
|
142
527
|
},
|
|
143
|
-
[client, workspaceId, sessionId, pausing],
|
|
528
|
+
[client, workspaceId, sessionId, pausing, options.effectiveControl?.controlEtag, targetKey],
|
|
144
529
|
);
|
|
145
530
|
|
|
146
531
|
const resume = useCallback(
|
|
147
532
|
async (reason?: string): Promise<void> => {
|
|
148
|
-
|
|
533
|
+
const ownedTargetKey = targetKey;
|
|
534
|
+
const ownedGeneration = targetGeneration.current;
|
|
535
|
+
if (!sessionId || resuming || targetKeyRef.current !== ownedTargetKey) return;
|
|
149
536
|
setResuming(true);
|
|
150
537
|
setError(null);
|
|
151
538
|
try {
|
|
152
|
-
await client.resumeSession(workspaceId, sessionId,
|
|
539
|
+
await client.resumeSession(workspaceId, sessionId, {
|
|
540
|
+
...(reason !== undefined ? { reason } : {}),
|
|
541
|
+
...(options.effectiveControl?.controlEtag
|
|
542
|
+
? { expectedControlEtag: options.effectiveControl.controlEtag }
|
|
543
|
+
: {}),
|
|
544
|
+
});
|
|
153
545
|
} catch (cause) {
|
|
154
|
-
|
|
546
|
+
if (
|
|
547
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
548
|
+
targetGeneration.current === ownedGeneration
|
|
549
|
+
) {
|
|
550
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
551
|
+
}
|
|
155
552
|
} finally {
|
|
156
|
-
|
|
553
|
+
if (
|
|
554
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
555
|
+
targetGeneration.current === ownedGeneration
|
|
556
|
+
) {
|
|
557
|
+
setResuming(false);
|
|
558
|
+
}
|
|
157
559
|
}
|
|
158
560
|
},
|
|
159
|
-
[client, workspaceId, sessionId, resuming],
|
|
561
|
+
[client, workspaceId, sessionId, resuming, options.effectiveControl?.controlEtag, targetKey],
|
|
160
562
|
);
|
|
161
563
|
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
564
|
+
const resumeScope = useCallback(
|
|
565
|
+
async (option: EffectiveControlResumeOption): Promise<void> => {
|
|
566
|
+
const ownedTargetKey = targetKey;
|
|
567
|
+
const ownedGeneration = targetGeneration.current;
|
|
568
|
+
if (!sessionId || resuming || targetKeyRef.current !== ownedTargetKey) return;
|
|
569
|
+
setResuming(true);
|
|
570
|
+
setError(null);
|
|
571
|
+
try {
|
|
572
|
+
if (option.scope === "workspace") {
|
|
573
|
+
if (!client.setWorkspaceInferenceState) {
|
|
574
|
+
throw new Error("Workspace-level resume is not available through this client");
|
|
575
|
+
}
|
|
576
|
+
const workspaceBlocker = options.effectiveControl?.blockers.find(
|
|
577
|
+
(blocker) => blocker.kind === "workspace",
|
|
578
|
+
);
|
|
579
|
+
await client.setWorkspaceInferenceState(workspaceId, {
|
|
580
|
+
action: "resume",
|
|
581
|
+
clientEventId: generateClientEventId(),
|
|
582
|
+
...(workspaceBlocker ? { expectedRevision: workspaceBlocker.revision } : {}),
|
|
583
|
+
});
|
|
584
|
+
} else if (option.scope === "session" && option.targetId) {
|
|
585
|
+
const target = await client.getQueue(workspaceId, option.targetId);
|
|
586
|
+
if (
|
|
587
|
+
targetKeyRef.current !== ownedTargetKey ||
|
|
588
|
+
targetGeneration.current !== ownedGeneration
|
|
589
|
+
) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
await client.resumeSession(workspaceId, option.targetId, {
|
|
593
|
+
expectedControlEtag: target.effectiveControl.controlEtag,
|
|
594
|
+
});
|
|
595
|
+
} else {
|
|
596
|
+
await client.resumeSession(workspaceId, sessionId, {
|
|
597
|
+
...(options.effectiveControl?.controlEtag
|
|
598
|
+
? { expectedControlEtag: options.effectiveControl.controlEtag }
|
|
599
|
+
: {}),
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
} catch (cause) {
|
|
603
|
+
if (
|
|
604
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
605
|
+
targetGeneration.current === ownedGeneration
|
|
606
|
+
) {
|
|
607
|
+
setError(asError(cause));
|
|
608
|
+
}
|
|
609
|
+
} finally {
|
|
610
|
+
if (
|
|
611
|
+
targetKeyRef.current === ownedTargetKey &&
|
|
612
|
+
targetGeneration.current === ownedGeneration
|
|
613
|
+
) {
|
|
614
|
+
setResuming(false);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
},
|
|
618
|
+
[client, options.effectiveControl, resuming, sessionId, targetKey, workspaceId],
|
|
619
|
+
);
|
|
620
|
+
|
|
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
|
+
);
|
|
630
|
+
|
|
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
|
+
);
|
|
639
|
+
|
|
640
|
+
const resolveDraftConflict = useCallback(
|
|
641
|
+
async (choice: "keep_mine" | "use_remote"): Promise<void> => {
|
|
642
|
+
const ownedTargetKey = targetKey;
|
|
643
|
+
const ownedGeneration = targetGeneration.current;
|
|
644
|
+
if (!sessionId || !durableDrafts || targetKeyRef.current !== ownedTargetKey) return;
|
|
645
|
+
const remote = await client.getComposerDraft(workspaceId, sessionId);
|
|
646
|
+
if (targetKeyRef.current !== ownedTargetKey || targetGeneration.current !== ownedGeneration) {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (choice === "use_remote") {
|
|
650
|
+
applyDraft(remote);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
draftRef.current = remote;
|
|
654
|
+
setDraft(remote);
|
|
655
|
+
setDraftConflict(null);
|
|
656
|
+
const payload = currentDraftPayload();
|
|
657
|
+
if (payload) await persistPayload({ ...payload, expectedRevision: remote.revision });
|
|
658
|
+
},
|
|
659
|
+
[
|
|
660
|
+
applyDraft,
|
|
661
|
+
client,
|
|
662
|
+
currentDraftPayload,
|
|
663
|
+
durableDrafts,
|
|
664
|
+
persistPayload,
|
|
665
|
+
sessionId,
|
|
666
|
+
targetKey,
|
|
667
|
+
workspaceId,
|
|
668
|
+
],
|
|
669
|
+
);
|
|
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]);
|
|
166
678
|
|
|
167
679
|
return {
|
|
168
|
-
value,
|
|
680
|
+
value: identityMatches ? value : "",
|
|
169
681
|
setValue: updateValue,
|
|
170
682
|
send,
|
|
171
|
-
|
|
172
|
-
|
|
683
|
+
steer,
|
|
684
|
+
sending: identityMatches ? sending : false,
|
|
685
|
+
canSend:
|
|
686
|
+
identityMatches &&
|
|
687
|
+
Boolean(sessionId) &&
|
|
688
|
+
!sending &&
|
|
689
|
+
(value.trim().length > 0 || hasReadyResources),
|
|
173
690
|
pause,
|
|
174
|
-
pausing,
|
|
691
|
+
pausing: identityMatches ? pausing : false,
|
|
175
692
|
resume,
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
693
|
+
resumeScope,
|
|
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",
|
|
701
|
+
applyDraft,
|
|
702
|
+
reloadDraft,
|
|
703
|
+
resolveDraftConflict,
|
|
704
|
+
restoredResources: identityMatches ? restoredResources : [],
|
|
705
|
+
removeRestoredResource,
|
|
706
|
+
error: identityMatches ? error : null,
|
|
707
|
+
clearError,
|
|
179
708
|
};
|
|
180
709
|
}
|
|
181
710
|
|
|
711
|
+
/** Events that can atomically replace or clear this subject's durable draft. */
|
|
712
|
+
export function isComposerDraftEvent(event: Pick<SessionEvent, "type">): boolean {
|
|
713
|
+
return event.type === "user.message" || event.type === "session.queue.changed";
|
|
714
|
+
}
|
|
715
|
+
|
|
182
716
|
/**
|
|
183
717
|
* Default text for a file-only message (attachment(s) present, no typed draft).
|
|
184
718
|
* Kept non-empty so the wire contract (`text: z.string().min(1)`) and the
|
|
@@ -202,8 +736,9 @@ export function composeSendInput(
|
|
|
202
736
|
text: string,
|
|
203
737
|
clientEventId: string,
|
|
204
738
|
extras: ComposerSendExtras | (() => ComposerSendExtras) | undefined,
|
|
739
|
+
bound: Partial<SendMessageInput> = {},
|
|
205
740
|
): SendMessageInput {
|
|
206
|
-
return { ...resolveSendExtras(extras), text, clientEventId };
|
|
741
|
+
return { ...resolveSendExtras(extras), ...bound, text, clientEventId };
|
|
207
742
|
}
|
|
208
743
|
|
|
209
744
|
/** Submit on plain Enter; Shift+Enter inserts a newline. Exported for tests. */
|
|
@@ -221,14 +756,40 @@ export function shouldSubmitOnKey(event: {
|
|
|
221
756
|
}
|
|
222
757
|
|
|
223
758
|
/** Cmd/Ctrl+Enter steers; ordinary Enter appends to the queue. */
|
|
224
|
-
export function
|
|
225
|
-
return event.metaKey || event.ctrlKey
|
|
759
|
+
export function shouldSteerOnKey(event: { metaKey?: boolean; ctrlKey?: boolean }): boolean {
|
|
760
|
+
return event.metaKey === true || event.ctrlKey === true;
|
|
226
761
|
}
|
|
227
762
|
|
|
228
763
|
function generateClientEventId(): string {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
return
|
|
764
|
+
return globalThis.crypto.randomUUID();
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function asError(cause: unknown): Error {
|
|
768
|
+
return cause instanceof Error ? cause : new Error(String(cause));
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function draftPayload(draft: ComposerDraft): SaveComposerDraftRequest {
|
|
772
|
+
return {
|
|
773
|
+
expectedRevision: draft.revision,
|
|
774
|
+
text: draft.text,
|
|
775
|
+
resources: draft.resources,
|
|
776
|
+
tools: draft.tools,
|
|
777
|
+
model: draft.model,
|
|
778
|
+
reasoningEffort: draft.reasoningEffort,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function draftSignature(payload: SaveComposerDraftRequest): string {
|
|
783
|
+
const { expectedRevision: _revision, ...content } = payload;
|
|
784
|
+
return JSON.stringify(content);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function mergeResources(base: ResourceRef[], additions: ResourceRef[]): ResourceRef[] {
|
|
788
|
+
const seen = new Set<string>();
|
|
789
|
+
return [...base, ...additions].filter((resource) => {
|
|
790
|
+
const key = JSON.stringify(resource);
|
|
791
|
+
if (seen.has(key)) return false;
|
|
792
|
+
seen.add(key);
|
|
793
|
+
return true;
|
|
794
|
+
});
|
|
234
795
|
}
|