@opengeni/react 0.12.0 → 0.15.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 +20 -14
- package/dist/chunk-TOJR776I.js +2280 -0
- package/dist/chunk-TOJR776I.js.map +1 -0
- package/dist/index.d.ts +1040 -363
- package/dist/index.js +10938 -6281
- package/dist/index.js.map +1 -1
- package/dist/{machines-BeZ3bD3t.d.ts → machines-BpdwuQcD.d.ts} +134 -14
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +23 -1
- package/package.json +20 -12
- package/src/approvals.ts +16 -4
- package/src/client.ts +41 -5
- package/src/commands/index.ts +1 -7
- package/src/commands/registry.ts +46 -12
- package/src/components/chat-composer.tsx +417 -69
- package/src/components/code-editor.tsx +28 -36
- package/src/components/command-palette.tsx +26 -5
- package/src/components/desktop-viewer.tsx +29 -16
- package/src/components/diff-view.tsx +27 -189
- package/src/components/enrollment-consent.tsx +28 -10
- package/src/components/enrollment-device-flow.tsx +8 -5
- package/src/components/file-browser.tsx +190 -56
- package/src/components/fleet-tile.tsx +13 -4
- package/src/components/machine-card.tsx +93 -15
- package/src/components/machine-dock-bar.tsx +20 -8
- package/src/components/machine-health-pill.tsx +68 -0
- package/src/components/machine-metrics.tsx +28 -19
- package/src/components/machine-status-pill.tsx +26 -5
- 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 +21 -3
- package/src/components/markdown.tsx +39 -9
- package/src/components/message-timeline.tsx +633 -109
- package/src/components/pierre-diff.tsx +83 -15
- package/src/components/pierre-file.tsx +10 -9
- package/src/components/queue-surface.tsx +578 -0
- package/src/components/sandbox-files.tsx +193 -225
- package/src/components/sandbox-terminal.tsx +256 -88
- package/src/components/sandbox-workspace.tsx +957 -0
- package/src/components/session-status.tsx +22 -2
- package/src/components/workbench-changes.tsx +585 -0
- package/src/components/workspace-dock.tsx +213 -59
- package/src/hooks/internal.ts +5 -2
- package/src/hooks/use-available-models.ts +7 -2
- package/src/hooks/use-billing-usage.ts +4 -1
- package/src/hooks/use-codex-accounts.ts +74 -44
- package/src/hooks/use-composer.ts +414 -63
- package/src/hooks/use-environments.ts +92 -47
- package/src/hooks/use-file-attachments.ts +101 -55
- package/src/hooks/use-goal.ts +52 -16
- package/src/hooks/use-machine-chip.ts +134 -0
- package/src/hooks/use-machines.ts +34 -16
- package/src/hooks/use-packs.ts +24 -19
- package/src/hooks/use-relay-frame-stream.ts +5 -2
- package/src/hooks/use-rigs.ts +335 -0
- package/src/hooks/use-sandbox-files.ts +213 -39
- package/src/hooks/use-sandbox-git.ts +188 -11
- package/src/hooks/use-sandbox-terminal.ts +18 -14
- package/src/hooks/use-scheduled-tasks.ts +10 -2
- package/src/hooks/use-session-capabilities.ts +77 -20
- package/src/hooks/use-session-control.ts +50 -23
- package/src/hooks/use-session-events.ts +50 -21
- package/src/hooks/use-session-lineage.ts +128 -0
- package/src/hooks/use-session.ts +53 -30
- package/src/hooks/use-slash-commands.ts +6 -4
- package/src/hooks/use-turn-queue.ts +202 -147
- package/src/hooks/use-windowed-sections.ts +204 -0
- package/src/hooks/use-workspace-capture.ts +233 -0
- package/src/hooks/use-workspace-edit.ts +231 -0
- package/src/hooks/use-workspace-sessions.ts +48 -5
- package/src/hooks/use-workspaces.ts +18 -15
- package/src/index.ts +133 -23
- package/src/lib/format.ts +3 -3
- package/src/lib/xterm-renderer.ts +65 -0
- package/src/lib/xterm-theme.ts +224 -18
- package/src/machines.ts +29 -3
- package/src/provider.tsx +195 -6
- package/src/timeline/activity-rail.tsx +213 -54
- package/src/timeline/disclosure-context.tsx +12 -2
- package/src/timeline/index.ts +21 -2
- package/src/timeline/parsers.ts +87 -16
- package/src/timeline/projection.ts +412 -87
- package/src/timeline/shared.tsx +111 -24
- package/src/timeline/tool-diff.tsx +24 -20
- package/src/timeline/tool-renderers.tsx +98 -21
- package/src/timeline/turn-summary.tsx +76 -23
- package/src/timeline/types.ts +96 -12
- package/styles/index.css +22 -0
- package/styles/tokens.css +2 -2
- package/dist/chunk-5TIXRCSI.js +0 -1291
- package/dist/chunk-5TIXRCSI.js.map +0 -1
|
@@ -1,69 +1,98 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ComposerDraft,
|
|
3
|
+
EffectiveControlResumeOption,
|
|
4
|
+
EffectiveSessionControl,
|
|
5
|
+
ResourceRef,
|
|
6
|
+
SaveComposerDraftRequest,
|
|
7
|
+
SendMessageInput,
|
|
8
|
+
SessionEvent,
|
|
9
|
+
} from "@opengeni/sdk";
|
|
2
10
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
11
|
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
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
|
-
sendExtras?: ComposerSendExtras | (() => ComposerSendExtras) | undefined;
|
|
23
|
-
/** Initial delivery mode. Defaults to `"queue"`. */
|
|
24
|
-
defaultMode?: ComposerMode | undefined;
|
|
25
|
-
};
|
|
16
|
+
export type UseComposerOptions = ClientOverride &
|
|
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
|
+
};
|
|
26
31
|
|
|
27
32
|
export type ComposerState = {
|
|
28
33
|
value: string;
|
|
29
34
|
setValue: (value: string) => void;
|
|
30
|
-
/**
|
|
35
|
+
/** Append the draft behind prompts already visible in the queue. */
|
|
31
36
|
send: (text?: string) => Promise<boolean>;
|
|
37
|
+
/** Supersede current direction with the draft. */
|
|
38
|
+
steer: (text?: string) => Promise<boolean>;
|
|
32
39
|
sending: boolean;
|
|
33
40
|
canSend: boolean;
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
/** Pause the session without deleting its prompt queue. */
|
|
42
|
+
pause: (reason?: string) => Promise<void>;
|
|
43
|
+
pausing: boolean;
|
|
44
|
+
resume: (reason?: string) => Promise<void>;
|
|
45
|
+
resumeScope: (option: EffectiveControlResumeOption) => Promise<void>;
|
|
46
|
+
resuming: boolean;
|
|
47
|
+
draft: ComposerDraft | null;
|
|
48
|
+
draftRevision: number;
|
|
49
|
+
draftLoading: boolean;
|
|
50
|
+
draftSaving: boolean;
|
|
51
|
+
draftConflict: Error | null;
|
|
52
|
+
/** Apply an atomic queue Edit checkout without a second read. */
|
|
53
|
+
applyDraft: (draft: ComposerDraft) => void;
|
|
54
|
+
reloadDraft: () => Promise<void>;
|
|
55
|
+
resolveDraftConflict: (choice: "keep_mine" | "use_remote") => Promise<void>;
|
|
56
|
+
restoredResources: ResourceRef[];
|
|
57
|
+
removeRestoredResource: (index: number) => void;
|
|
40
58
|
error: Error | null;
|
|
41
59
|
clearError: () => void;
|
|
42
60
|
};
|
|
43
61
|
|
|
44
62
|
/**
|
|
45
|
-
* Draft + send +
|
|
63
|
+
* Draft + send + Pause/Resume state for the chat composer — the only
|
|
46
64
|
* human-to-agent input surface. The draft survives a failed send (nothing is
|
|
47
65
|
* more hostile than losing a typed message); each send carries a generated
|
|
48
66
|
* `clientEventId` so retries stay idempotent server-side.
|
|
49
67
|
*/
|
|
50
|
-
export function useComposer(
|
|
51
|
-
|
|
52
|
-
|
|
68
|
+
export function useComposer(
|
|
69
|
+
sessionId: string | null | undefined,
|
|
70
|
+
options: UseComposerOptions = {},
|
|
71
|
+
): ComposerState {
|
|
72
|
+
const { client, workspaceId, registerSessionReconciler } = useOpenGeni(options);
|
|
53
73
|
const [value, setValue] = useState("");
|
|
54
|
-
const [mode, setMode] = useState<ComposerMode>(defaultMode);
|
|
55
74
|
const [sending, setSending] = useState(false);
|
|
56
|
-
const [
|
|
75
|
+
const [pausing, setPausing] = useState(false);
|
|
76
|
+
const [resuming, setResuming] = useState(false);
|
|
57
77
|
const [error, setError] = useState<Error | null>(null);
|
|
78
|
+
const [draft, setDraft] = useState<ComposerDraft | null>(null);
|
|
79
|
+
const [draftLoading, setDraftLoading] = useState(Boolean(sessionId));
|
|
80
|
+
const [draftSaving, setDraftSaving] = useState(false);
|
|
81
|
+
const [draftConflict, setDraftConflict] = useState<Error | null>(null);
|
|
82
|
+
const [restoredResources, setRestoredResources] = useState<ResourceRef[]>([]);
|
|
58
83
|
const pendingClientEventId = useRef<string | null>(null);
|
|
84
|
+
const draftRef = useRef<ComposerDraft | null>(null);
|
|
85
|
+
const localEditRevision = useRef(0);
|
|
86
|
+
const targetGeneration = useRef(0);
|
|
87
|
+
const lastSavedSignature = useRef<string | null>(null);
|
|
88
|
+
const saveChain = useRef<Promise<void>>(Promise.resolve());
|
|
59
89
|
const onSent = options.onSent;
|
|
60
|
-
|
|
61
|
-
const modeRef = useRef(mode);
|
|
62
|
-
modeRef.current = mode;
|
|
90
|
+
const onDraftApplied = options.onDraftApplied;
|
|
63
91
|
// Read through a ref so a new extras closure (created every render by
|
|
64
92
|
// callers passing inline functions) does not invalidate `send`.
|
|
65
93
|
const sendExtrasRef = useRef(options.sendExtras);
|
|
66
94
|
sendExtrasRef.current = options.sendExtras;
|
|
95
|
+
const liveExtrasVersion = JSON.stringify(resolveSendExtras(options.sendExtras));
|
|
67
96
|
|
|
68
97
|
// A composer is bound to one session: switching targets must not leak the
|
|
69
98
|
// previous session's draft, error, or retry idempotency key.
|
|
@@ -72,21 +101,160 @@ export function useComposer(sessionId: string | null | undefined, options: UseCo
|
|
|
72
101
|
useEffect(() => {
|
|
73
102
|
if (targetKeyRef.current !== targetKey) {
|
|
74
103
|
targetKeyRef.current = targetKey;
|
|
104
|
+
targetGeneration.current += 1;
|
|
75
105
|
pendingClientEventId.current = null;
|
|
106
|
+
localEditRevision.current = 0;
|
|
107
|
+
draftRef.current = null;
|
|
108
|
+
lastSavedSignature.current = null;
|
|
76
109
|
setValue("");
|
|
77
110
|
setError(null);
|
|
78
|
-
|
|
111
|
+
setDraft(null);
|
|
112
|
+
setDraftConflict(null);
|
|
113
|
+
setRestoredResources([]);
|
|
114
|
+
}
|
|
115
|
+
}, [targetKey]);
|
|
116
|
+
|
|
117
|
+
const applyDraft = useCallback(
|
|
118
|
+
(next: ComposerDraft): void => {
|
|
119
|
+
draftRef.current = next;
|
|
120
|
+
lastSavedSignature.current = draftSignature(draftPayload(next));
|
|
121
|
+
localEditRevision.current += 1;
|
|
122
|
+
pendingClientEventId.current = null;
|
|
123
|
+
setDraft(next);
|
|
124
|
+
setValue(next.text);
|
|
125
|
+
setRestoredResources(next.resources);
|
|
126
|
+
setDraftConflict(null);
|
|
127
|
+
onDraftApplied?.(next);
|
|
128
|
+
},
|
|
129
|
+
[onDraftApplied],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const loadDraft = useCallback(
|
|
133
|
+
async (replaceLocal: boolean): Promise<void> => {
|
|
134
|
+
if (!sessionId) return;
|
|
135
|
+
const generation = targetGeneration.current;
|
|
136
|
+
const localAtStart = localEditRevision.current;
|
|
137
|
+
setDraftLoading(true);
|
|
138
|
+
try {
|
|
139
|
+
const fetched = await client.getComposerDraft(workspaceId, sessionId);
|
|
140
|
+
if (generation !== targetGeneration.current) return;
|
|
141
|
+
draftRef.current = fetched;
|
|
142
|
+
setDraft(fetched);
|
|
143
|
+
setDraftConflict(null);
|
|
144
|
+
if (replaceLocal || localAtStart === localEditRevision.current) {
|
|
145
|
+
lastSavedSignature.current = draftSignature(draftPayload(fetched));
|
|
146
|
+
setValue(fetched.text);
|
|
147
|
+
setRestoredResources(fetched.resources);
|
|
148
|
+
onDraftApplied?.(fetched);
|
|
149
|
+
}
|
|
150
|
+
} catch (cause) {
|
|
151
|
+
if (generation === targetGeneration.current) setError(asError(cause));
|
|
152
|
+
} finally {
|
|
153
|
+
if (generation === targetGeneration.current) setDraftLoading(false);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
[client, onDraftApplied, sessionId, workspaceId],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (!sessionId) {
|
|
161
|
+
setDraftLoading(false);
|
|
162
|
+
return;
|
|
79
163
|
}
|
|
80
|
-
|
|
164
|
+
void loadDraft(false);
|
|
165
|
+
}, [loadDraft, sessionId]);
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (!sessionId) return;
|
|
168
|
+
return registerSessionReconciler(sessionId, "composer", async () => await loadDraft(false));
|
|
169
|
+
}, [loadDraft, registerSessionReconciler, sessionId]);
|
|
170
|
+
useSessionEventTrigger(
|
|
171
|
+
client,
|
|
172
|
+
workspaceId,
|
|
173
|
+
sessionId,
|
|
174
|
+
isComposerDraftEvent,
|
|
175
|
+
() => void loadDraft(false),
|
|
176
|
+
{
|
|
177
|
+
enabled: Boolean(sessionId),
|
|
178
|
+
...(options.events !== undefined ? { events: options.events } : {}),
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const currentDraftPayload = useCallback((): SaveComposerDraftRequest | null => {
|
|
183
|
+
const base = draftRef.current;
|
|
184
|
+
if (!base) return null;
|
|
185
|
+
const extras = resolveSendExtras(sendExtrasRef.current);
|
|
186
|
+
return {
|
|
187
|
+
expectedRevision: base.revision,
|
|
188
|
+
text: value,
|
|
189
|
+
resources: mergeResources(restoredResources, extras.resources ?? []),
|
|
190
|
+
tools: extras.tools ?? base.tools,
|
|
191
|
+
model: extras.model ?? base.model,
|
|
192
|
+
reasoningEffort: extras.reasoningEffort ?? base.reasoningEffort,
|
|
193
|
+
};
|
|
194
|
+
}, [restoredResources, value]);
|
|
195
|
+
|
|
196
|
+
const persistPayload = useCallback(
|
|
197
|
+
async (payload: SaveComposerDraftRequest): Promise<boolean> => {
|
|
198
|
+
if (!sessionId) return false;
|
|
199
|
+
let success = false;
|
|
200
|
+
const run = async () => {
|
|
201
|
+
const current = draftRef.current;
|
|
202
|
+
if (!current) return;
|
|
203
|
+
const request = { ...payload, expectedRevision: current.revision };
|
|
204
|
+
const signature = draftSignature(request);
|
|
205
|
+
if (signature === lastSavedSignature.current) {
|
|
206
|
+
success = true;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
setDraftSaving(true);
|
|
210
|
+
try {
|
|
211
|
+
const saved = await client.saveComposerDraft(workspaceId, sessionId, request);
|
|
212
|
+
draftRef.current = saved;
|
|
213
|
+
setDraft(saved);
|
|
214
|
+
lastSavedSignature.current = signature;
|
|
215
|
+
setDraftConflict(null);
|
|
216
|
+
success = true;
|
|
217
|
+
} catch (cause) {
|
|
218
|
+
const problem = asError(cause);
|
|
219
|
+
setDraftConflict(problem);
|
|
220
|
+
setError(problem);
|
|
221
|
+
} finally {
|
|
222
|
+
setDraftSaving(false);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
saveChain.current = saveChain.current.then(run, run);
|
|
226
|
+
await saveChain.current;
|
|
227
|
+
return success;
|
|
228
|
+
},
|
|
229
|
+
[client, sessionId, workspaceId],
|
|
230
|
+
);
|
|
81
231
|
|
|
82
|
-
|
|
83
|
-
|
|
232
|
+
// Private durable autosave. A newer local edit is never replaced by an older
|
|
233
|
+
// response; saves serialize and each reads the latest acknowledged revision.
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
if (!sessionId || draftLoading || sending || !draftRef.current || draftConflict) return;
|
|
236
|
+
const payload = currentDraftPayload();
|
|
237
|
+
if (!payload || draftSignature(payload) === lastSavedSignature.current) return;
|
|
238
|
+
const timer = window.setTimeout(() => void persistPayload(payload), 500);
|
|
239
|
+
return () => window.clearTimeout(timer);
|
|
240
|
+
}, [
|
|
241
|
+
currentDraftPayload,
|
|
242
|
+
draftConflict,
|
|
243
|
+
draftLoading,
|
|
244
|
+
liveExtrasVersion,
|
|
245
|
+
persistPayload,
|
|
246
|
+
sending,
|
|
247
|
+
sessionId,
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
const dispatch = useCallback(
|
|
251
|
+
async (delivery: "send" | "steer", explicit?: string): Promise<boolean> => {
|
|
84
252
|
const draftAtSend = value;
|
|
85
253
|
const text = (explicit ?? draftAtSend).trim();
|
|
86
254
|
// Resolve the extras once: a file-only message (empty text + ≥1 ready
|
|
87
255
|
// resource) is legitimate, so we must not bail on empty text alone.
|
|
88
256
|
const extras = resolveSendExtras(sendExtrasRef.current);
|
|
89
|
-
const hasResources = (extras.resources?.length ?? 0) > 0;
|
|
257
|
+
const hasResources = restoredResources.length > 0 || (extras.resources?.length ?? 0) > 0;
|
|
90
258
|
if ((!text && !hasResources) || !sessionId || sending) {
|
|
91
259
|
return false;
|
|
92
260
|
}
|
|
@@ -96,17 +264,41 @@ export function useComposer(sessionId: string | null | undefined, options: UseCo
|
|
|
96
264
|
setSending(true);
|
|
97
265
|
setError(null);
|
|
98
266
|
try {
|
|
267
|
+
const payload = currentDraftPayload();
|
|
268
|
+
if (payload && !(await persistPayload(payload))) return false;
|
|
99
269
|
// The wire contract requires non-empty text (z.string().min(1)) and the
|
|
100
270
|
// worker rejects whitespace-only text; a file-only message therefore
|
|
101
271
|
// carries a minimal default so the attachments still get delivered.
|
|
102
272
|
const sendText = text || FILE_ONLY_MESSAGE_TEXT;
|
|
103
|
-
const input = composeSendInput(sendText, pendingClientEventId.current, extras
|
|
104
|
-
|
|
273
|
+
const input = composeSendInput(sendText, pendingClientEventId.current, extras, {
|
|
274
|
+
...(options.effectiveControl?.controlEtag
|
|
275
|
+
? { controlEtag: options.effectiveControl.controlEtag }
|
|
276
|
+
: {}),
|
|
277
|
+
...(draftRef.current ? { expectedDraftRevision: draftRef.current.revision } : {}),
|
|
278
|
+
resources: mergeResources(restoredResources, extras.resources ?? []),
|
|
279
|
+
});
|
|
280
|
+
if (delivery === "steer") {
|
|
105
281
|
await client.steerMessage(workspaceId, sessionId, input);
|
|
106
282
|
} else {
|
|
107
283
|
await client.sendMessage(workspaceId, sessionId, input);
|
|
108
284
|
}
|
|
109
285
|
pendingClientEventId.current = null;
|
|
286
|
+
const previousDraft = draftRef.current;
|
|
287
|
+
if (previousDraft) {
|
|
288
|
+
const cleared = {
|
|
289
|
+
...previousDraft,
|
|
290
|
+
revision: 0,
|
|
291
|
+
text: "",
|
|
292
|
+
resources: [],
|
|
293
|
+
sourceTurnId: null,
|
|
294
|
+
sourceTurnVersion: null,
|
|
295
|
+
updatedAt: null,
|
|
296
|
+
};
|
|
297
|
+
draftRef.current = cleared;
|
|
298
|
+
setDraft(cleared);
|
|
299
|
+
setRestoredResources([]);
|
|
300
|
+
lastSavedSignature.current = draftSignature(draftPayload(cleared));
|
|
301
|
+
}
|
|
110
302
|
if (explicit === undefined) {
|
|
111
303
|
// Clear only the draft that was sent: edits made while the request
|
|
112
304
|
// was in flight were never delivered and must survive.
|
|
@@ -121,54 +313,175 @@ export function useComposer(sessionId: string | null | undefined, options: UseCo
|
|
|
121
313
|
setSending(false);
|
|
122
314
|
}
|
|
123
315
|
},
|
|
124
|
-
[
|
|
316
|
+
[
|
|
317
|
+
client,
|
|
318
|
+
currentDraftPayload,
|
|
319
|
+
onSent,
|
|
320
|
+
options.effectiveControl?.controlEtag,
|
|
321
|
+
persistPayload,
|
|
322
|
+
restoredResources,
|
|
323
|
+
sending,
|
|
324
|
+
sessionId,
|
|
325
|
+
value,
|
|
326
|
+
workspaceId,
|
|
327
|
+
],
|
|
125
328
|
);
|
|
126
329
|
|
|
330
|
+
const send = useCallback(async (text?: string) => await dispatch("send", text), [dispatch]);
|
|
331
|
+
const steer = useCallback(async (text?: string) => await dispatch("steer", text), [dispatch]);
|
|
332
|
+
|
|
127
333
|
// A send is possible with non-empty text OR with ≥1 attached resource (a
|
|
128
334
|
// file-only message). Resources ride in `sendExtras`, so we resolve them here
|
|
129
335
|
// — keeping useComposer attachment-agnostic while still lighting up the send
|
|
130
336
|
// affordance the moment a file is ready. ChatComposer additionally gates this
|
|
131
337
|
// on its `attachments.uploading` flag so a message never departs mid-upload.
|
|
132
|
-
const hasReadyResources =
|
|
338
|
+
const hasReadyResources =
|
|
339
|
+
restoredResources.length > 0 ||
|
|
340
|
+
(resolveSendExtras(sendExtrasRef.current).resources?.length ?? 0) > 0;
|
|
133
341
|
|
|
134
|
-
const
|
|
342
|
+
const pause = useCallback(
|
|
135
343
|
async (reason?: string): Promise<void> => {
|
|
136
|
-
if (!sessionId ||
|
|
344
|
+
if (!sessionId || pausing) {
|
|
137
345
|
return;
|
|
138
346
|
}
|
|
139
|
-
|
|
347
|
+
setPausing(true);
|
|
348
|
+
setError(null);
|
|
349
|
+
try {
|
|
350
|
+
await client.pauseSession(workspaceId, sessionId, {
|
|
351
|
+
...(reason !== undefined ? { reason } : {}),
|
|
352
|
+
...(options.effectiveControl?.controlEtag
|
|
353
|
+
? { expectedControlEtag: options.effectiveControl.controlEtag }
|
|
354
|
+
: {}),
|
|
355
|
+
});
|
|
356
|
+
} catch (cause) {
|
|
357
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
358
|
+
} finally {
|
|
359
|
+
setPausing(false);
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
[client, workspaceId, sessionId, pausing, options.effectiveControl?.controlEtag],
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
const resume = useCallback(
|
|
366
|
+
async (reason?: string): Promise<void> => {
|
|
367
|
+
if (!sessionId || resuming) return;
|
|
368
|
+
setResuming(true);
|
|
140
369
|
setError(null);
|
|
141
370
|
try {
|
|
142
|
-
await client.
|
|
371
|
+
await client.resumeSession(workspaceId, sessionId, {
|
|
372
|
+
...(reason !== undefined ? { reason } : {}),
|
|
373
|
+
...(options.effectiveControl?.controlEtag
|
|
374
|
+
? { expectedControlEtag: options.effectiveControl.controlEtag }
|
|
375
|
+
: {}),
|
|
376
|
+
});
|
|
143
377
|
} catch (cause) {
|
|
144
378
|
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
145
379
|
} finally {
|
|
146
|
-
|
|
380
|
+
setResuming(false);
|
|
147
381
|
}
|
|
148
382
|
},
|
|
149
|
-
[client, workspaceId, sessionId,
|
|
383
|
+
[client, workspaceId, sessionId, resuming, options.effectiveControl?.controlEtag],
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
const resumeScope = useCallback(
|
|
387
|
+
async (option: EffectiveControlResumeOption): Promise<void> => {
|
|
388
|
+
if (!sessionId || resuming) return;
|
|
389
|
+
setResuming(true);
|
|
390
|
+
setError(null);
|
|
391
|
+
try {
|
|
392
|
+
if (option.scope === "workspace") {
|
|
393
|
+
const workspaceBlocker = options.effectiveControl?.blockers.find(
|
|
394
|
+
(blocker) => blocker.kind === "workspace",
|
|
395
|
+
);
|
|
396
|
+
await client.setWorkspaceInferenceState(workspaceId, {
|
|
397
|
+
action: "resume",
|
|
398
|
+
clientEventId: generateClientEventId(),
|
|
399
|
+
...(workspaceBlocker ? { expectedRevision: workspaceBlocker.revision } : {}),
|
|
400
|
+
});
|
|
401
|
+
} else if (option.scope === "session" && option.targetId) {
|
|
402
|
+
const target = await client.getQueue(workspaceId, option.targetId);
|
|
403
|
+
await client.resumeSession(workspaceId, option.targetId, {
|
|
404
|
+
expectedControlEtag: target.effectiveControl.controlEtag,
|
|
405
|
+
});
|
|
406
|
+
} else {
|
|
407
|
+
await client.resumeSession(workspaceId, sessionId, {
|
|
408
|
+
...(options.effectiveControl?.controlEtag
|
|
409
|
+
? { expectedControlEtag: options.effectiveControl.controlEtag }
|
|
410
|
+
: {}),
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
} catch (cause) {
|
|
414
|
+
setError(asError(cause));
|
|
415
|
+
} finally {
|
|
416
|
+
setResuming(false);
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
[client, options.effectiveControl, resuming, sessionId, workspaceId],
|
|
150
420
|
);
|
|
151
421
|
|
|
152
422
|
const updateValue = useCallback((next: string) => {
|
|
153
423
|
pendingClientEventId.current = null;
|
|
424
|
+
localEditRevision.current += 1;
|
|
154
425
|
setValue(next);
|
|
155
426
|
}, []);
|
|
156
427
|
|
|
428
|
+
const removeRestoredResource = useCallback((index: number) => {
|
|
429
|
+
localEditRevision.current += 1;
|
|
430
|
+
setRestoredResources((current) => current.filter((_, candidate) => candidate !== index));
|
|
431
|
+
}, []);
|
|
432
|
+
|
|
433
|
+
const resolveDraftConflict = useCallback(
|
|
434
|
+
async (choice: "keep_mine" | "use_remote"): Promise<void> => {
|
|
435
|
+
if (!sessionId) return;
|
|
436
|
+
const remote = await client.getComposerDraft(workspaceId, sessionId);
|
|
437
|
+
if (choice === "use_remote") {
|
|
438
|
+
applyDraft(remote);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
draftRef.current = remote;
|
|
442
|
+
setDraft(remote);
|
|
443
|
+
setDraftConflict(null);
|
|
444
|
+
const payload = currentDraftPayload();
|
|
445
|
+
if (payload) await persistPayload({ ...payload, expectedRevision: remote.revision });
|
|
446
|
+
},
|
|
447
|
+
[applyDraft, client, currentDraftPayload, persistPayload, sessionId, workspaceId],
|
|
448
|
+
);
|
|
449
|
+
|
|
157
450
|
return {
|
|
158
451
|
value,
|
|
159
452
|
setValue: updateValue,
|
|
160
453
|
send,
|
|
454
|
+
steer,
|
|
161
455
|
sending,
|
|
162
456
|
canSend: Boolean(sessionId) && !sending && (value.trim().length > 0 || hasReadyResources),
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
457
|
+
pause,
|
|
458
|
+
pausing,
|
|
459
|
+
resume,
|
|
460
|
+
resumeScope,
|
|
461
|
+
resuming,
|
|
462
|
+
draft,
|
|
463
|
+
draftRevision: draft?.revision ?? 0,
|
|
464
|
+
draftLoading,
|
|
465
|
+
draftSaving,
|
|
466
|
+
draftConflict,
|
|
467
|
+
applyDraft,
|
|
468
|
+
reloadDraft: useCallback(async () => await loadDraft(true), [loadDraft]),
|
|
469
|
+
resolveDraftConflict,
|
|
470
|
+
restoredResources,
|
|
471
|
+
removeRestoredResource,
|
|
167
472
|
error,
|
|
168
|
-
clearError: useCallback(() =>
|
|
473
|
+
clearError: useCallback(() => {
|
|
474
|
+
setError(null);
|
|
475
|
+
setDraftConflict(null);
|
|
476
|
+
}, []),
|
|
169
477
|
};
|
|
170
478
|
}
|
|
171
479
|
|
|
480
|
+
/** Events that can atomically replace or clear this subject's durable draft. */
|
|
481
|
+
export function isComposerDraftEvent(event: Pick<SessionEvent, "type">): boolean {
|
|
482
|
+
return event.type === "user.message" || event.type === "session.queue.changed";
|
|
483
|
+
}
|
|
484
|
+
|
|
172
485
|
/**
|
|
173
486
|
* Default text for a file-only message (attachment(s) present, no typed draft).
|
|
174
487
|
* Kept non-empty so the wire contract (`text: z.string().min(1)`) and the
|
|
@@ -192,22 +505,60 @@ export function composeSendInput(
|
|
|
192
505
|
text: string,
|
|
193
506
|
clientEventId: string,
|
|
194
507
|
extras: ComposerSendExtras | (() => ComposerSendExtras) | undefined,
|
|
508
|
+
bound: Partial<SendMessageInput> = {},
|
|
195
509
|
): SendMessageInput {
|
|
196
|
-
return { ...resolveSendExtras(extras), text, clientEventId };
|
|
510
|
+
return { ...resolveSendExtras(extras), ...bound, text, clientEventId };
|
|
197
511
|
}
|
|
198
512
|
|
|
199
513
|
/** Submit on plain Enter; Shift+Enter inserts a newline. Exported for tests. */
|
|
200
|
-
export function shouldSubmitOnKey(event: {
|
|
514
|
+
export function shouldSubmitOnKey(event: {
|
|
515
|
+
key: string;
|
|
516
|
+
shiftKey: boolean;
|
|
517
|
+
metaKey?: boolean;
|
|
518
|
+
ctrlKey?: boolean;
|
|
519
|
+
nativeEvent?: { isComposing?: boolean };
|
|
520
|
+
}): boolean {
|
|
201
521
|
if (event.key !== "Enter" || event.shiftKey) {
|
|
202
522
|
return false;
|
|
203
523
|
}
|
|
204
524
|
return event.nativeEvent?.isComposing !== true;
|
|
205
525
|
}
|
|
206
526
|
|
|
527
|
+
/** Cmd/Ctrl+Enter steers; ordinary Enter appends to the queue. */
|
|
528
|
+
export function shouldSteerOnKey(event: { metaKey?: boolean; ctrlKey?: boolean }): boolean {
|
|
529
|
+
return event.metaKey === true || event.ctrlKey === true;
|
|
530
|
+
}
|
|
531
|
+
|
|
207
532
|
function generateClientEventId(): string {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
return
|
|
533
|
+
return globalThis.crypto.randomUUID();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function asError(cause: unknown): Error {
|
|
537
|
+
return cause instanceof Error ? cause : new Error(String(cause));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function draftPayload(draft: ComposerDraft): SaveComposerDraftRequest {
|
|
541
|
+
return {
|
|
542
|
+
expectedRevision: draft.revision,
|
|
543
|
+
text: draft.text,
|
|
544
|
+
resources: draft.resources,
|
|
545
|
+
tools: draft.tools,
|
|
546
|
+
model: draft.model,
|
|
547
|
+
reasoningEffort: draft.reasoningEffort,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function draftSignature(payload: SaveComposerDraftRequest): string {
|
|
552
|
+
const { expectedRevision: _revision, ...content } = payload;
|
|
553
|
+
return JSON.stringify(content);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function mergeResources(base: ResourceRef[], additions: ResourceRef[]): ResourceRef[] {
|
|
557
|
+
const seen = new Set<string>();
|
|
558
|
+
return [...base, ...additions].filter((resource) => {
|
|
559
|
+
const key = JSON.stringify(resource);
|
|
560
|
+
if (seen.has(key)) return false;
|
|
561
|
+
seen.add(key);
|
|
562
|
+
return true;
|
|
563
|
+
});
|
|
213
564
|
}
|