@opengeni/react 0.1.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 +141 -0
- package/dist/index.d.ts +897 -0
- package/dist/index.js +3013 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
- package/src/approvals.ts +86 -0
- package/src/client.ts +52 -0
- package/src/commands/index.ts +17 -0
- package/src/commands/registry.ts +236 -0
- package/src/commands/types.ts +88 -0
- package/src/components/chat-composer.tsx +619 -0
- package/src/components/command-palette.tsx +94 -0
- package/src/components/fleet-tile.tsx +72 -0
- package/src/components/message-timeline.tsx +416 -0
- package/src/components/session-status.tsx +92 -0
- package/src/hooks/internal.ts +236 -0
- package/src/hooks/use-billing-usage.ts +51 -0
- package/src/hooks/use-composer.ts +213 -0
- package/src/hooks/use-environments.ts +118 -0
- package/src/hooks/use-file-attachments.ts +135 -0
- package/src/hooks/use-goal.ts +154 -0
- package/src/hooks/use-packs.ts +101 -0
- package/src/hooks/use-scheduled-tasks.ts +29 -0
- package/src/hooks/use-session-control.ts +85 -0
- package/src/hooks/use-session-events.ts +130 -0
- package/src/hooks/use-session.ts +33 -0
- package/src/hooks/use-slash-commands.ts +366 -0
- package/src/hooks/use-turn-queue.ts +229 -0
- package/src/hooks/use-workspace-sessions.ts +30 -0
- package/src/hooks/use-workspaces.ts +66 -0
- package/src/index.ts +115 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/format.ts +84 -0
- package/src/provider.tsx +57 -0
- package/src/timeline.ts +632 -0
- package/styles/index.css +157 -0
- package/styles/tokens.css +111 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import type { KeyboardEvent } from "react";
|
|
2
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
argHint,
|
|
5
|
+
filterCommands,
|
|
6
|
+
firstMissingRequiredArg,
|
|
7
|
+
matchCommand,
|
|
8
|
+
parseCommandLine,
|
|
9
|
+
} from "../commands/registry";
|
|
10
|
+
import type { CommandContext, Notice, SlashCommand } from "../commands/types";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Context the composer supplies for command execution and visibility. The
|
|
14
|
+
* composer owns the UI affordances (notice/openHelp/clearView/confirm), so they
|
|
15
|
+
* are NOT part of this slice — the hook closes over them via `handlers`.
|
|
16
|
+
*/
|
|
17
|
+
export type SlashCommandContext = Pick<
|
|
18
|
+
CommandContext,
|
|
19
|
+
"client" | "workspaceId" | "sessionId" | "status" | "permissions"
|
|
20
|
+
>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* UI affordances the composer supplies. `confirm` differs from the registry-
|
|
24
|
+
* facing {@link CommandContext.confirm} (which takes no args): the composer's
|
|
25
|
+
* confirm receives the command being run so the confirm bar renders from that
|
|
26
|
+
* exact command's identity. The hook bridges the two in {@link buildContext}.
|
|
27
|
+
*/
|
|
28
|
+
export type SlashCommandHandlers = Pick<CommandContext, "notice" | "openHelp" | "clearView"> & {
|
|
29
|
+
confirm: (command: SlashCommand) => Promise<boolean>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ConfirmState = {
|
|
33
|
+
command: SlashCommand;
|
|
34
|
+
/** Resolve the pending confirm() promise. */
|
|
35
|
+
resolve: (confirmed: boolean) => void;
|
|
36
|
+
} | null;
|
|
37
|
+
|
|
38
|
+
export type UseSlashCommandsOptions = {
|
|
39
|
+
commands: readonly SlashCommand[];
|
|
40
|
+
context: SlashCommandContext | undefined;
|
|
41
|
+
handlers: SlashCommandHandlers;
|
|
42
|
+
/** The current composer draft. */
|
|
43
|
+
value: string;
|
|
44
|
+
/** Replace the composer draft (autocomplete writes through this). */
|
|
45
|
+
setValue: (value: string) => void;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type UseSlashCommandsResult = {
|
|
49
|
+
/** Whether the palette is open (a command token is being typed). */
|
|
50
|
+
open: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Whether the draft is a slash-command attempt (matches a registered command)
|
|
53
|
+
* — true even after Escape dismisses the popover. The composer blocks its send
|
|
54
|
+
* path while this holds so a command can't be delivered to the agent as chat.
|
|
55
|
+
*/
|
|
56
|
+
isCommandDraft: boolean;
|
|
57
|
+
/** Commands shown for the current token + context, in display order. */
|
|
58
|
+
items: SlashCommand[];
|
|
59
|
+
/** Index into `items` of the highlighted row. */
|
|
60
|
+
highlight: number;
|
|
61
|
+
setHighlight: (index: number) => void;
|
|
62
|
+
/** The matched command once the name is closed by a space (arg-hint mode). */
|
|
63
|
+
activeCommand: SlashCommand | null;
|
|
64
|
+
/** The arg hint string for the active command (footer), or "". */
|
|
65
|
+
activeArgHint: string;
|
|
66
|
+
/**
|
|
67
|
+
* Key handler for the textarea. Returns true when it consumed the event
|
|
68
|
+
* (the composer must then NOT run its send path). Only consumes while open.
|
|
69
|
+
*/
|
|
70
|
+
onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => boolean;
|
|
71
|
+
/** Run the highlighted command (or the active command in arg-hint mode). */
|
|
72
|
+
runHighlighted: () => Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Run the command at an explicitly chosen index (a pointer click on a row).
|
|
75
|
+
* Bypasses the exact-match token heuristic that runHighlighted uses for
|
|
76
|
+
* keyboard Enter, so an explicit click always runs the clicked command.
|
|
77
|
+
*/
|
|
78
|
+
runAt: (index: number) => Promise<void>;
|
|
79
|
+
/** Autocomplete the highlighted command name + a trailing space. */
|
|
80
|
+
autocompleteHighlighted: () => void;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export function useSlashCommands(options: UseSlashCommandsOptions): UseSlashCommandsResult {
|
|
84
|
+
const { commands, context, handlers, value, setValue } = options;
|
|
85
|
+
const [highlight, setHighlight] = useState(0);
|
|
86
|
+
// Escape closes the palette but keeps the draft. We remember the dismissed
|
|
87
|
+
// value; any further edit (value !== dismissed) re-opens the palette.
|
|
88
|
+
const [dismissedValue, setDismissedValue] = useState<string | null>(null);
|
|
89
|
+
const dismissed = dismissedValue !== null && dismissedValue === value;
|
|
90
|
+
|
|
91
|
+
// Whether the operator has explicitly arrow-navigated the highlight for the
|
|
92
|
+
// CURRENT draft. When they have, Enter is an explicit choice of the
|
|
93
|
+
// highlighted row — the exact-match token override must NOT hijack it (else
|
|
94
|
+
// ArrowDown to /clear-view + Enter would still fire the destructive /clear,
|
|
95
|
+
// since "clear" exact-matches the token). Reset whenever the draft changes.
|
|
96
|
+
const navigatedRef = useRef(false);
|
|
97
|
+
const navTokenRef = useRef(value);
|
|
98
|
+
if (navTokenRef.current !== value) {
|
|
99
|
+
navTokenRef.current = value;
|
|
100
|
+
navigatedRef.current = false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const parsed = useMemo(() => parseCommandLine(value), [value]);
|
|
104
|
+
const filterCtx = useMemo(
|
|
105
|
+
() => ({
|
|
106
|
+
sessionId: context?.sessionId ?? null,
|
|
107
|
+
status: context?.status ?? null,
|
|
108
|
+
permissions: context?.permissions ?? [],
|
|
109
|
+
}),
|
|
110
|
+
[context?.sessionId, context?.status, context?.permissions],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// In arg-hint mode ("/name "), the list collapses to the matched command so
|
|
114
|
+
// the palette shows just its arg hint; while typing the name it filters.
|
|
115
|
+
const activeCommand = useMemo(() => {
|
|
116
|
+
if (!parsed || !parsed.hasTrailingSpace) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
return matchCommand(commands, value);
|
|
120
|
+
}, [commands, value, parsed]);
|
|
121
|
+
|
|
122
|
+
const items = useMemo(() => {
|
|
123
|
+
if (!parsed) {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
if (activeCommand) {
|
|
127
|
+
return [activeCommand];
|
|
128
|
+
}
|
|
129
|
+
return filterCommands(commands, parsed.name, filterCtx);
|
|
130
|
+
}, [commands, parsed, activeCommand, filterCtx]);
|
|
131
|
+
|
|
132
|
+
const open = parsed !== null && items.length > 0 && !dismissed;
|
|
133
|
+
|
|
134
|
+
// Whether the current draft is a slash-command ATTEMPT that should be run via
|
|
135
|
+
// the palette, never delivered to the agent as plain chat. True even when the
|
|
136
|
+
// palette is dismissed (Escape) — the draft still starts with "/" and matches
|
|
137
|
+
// a command, so the composer must block its send path (button + Enter) to keep
|
|
138
|
+
// commands from leaking into the conversation as messages the model reads.
|
|
139
|
+
const isCommandDraft = parsed !== null && items.length > 0;
|
|
140
|
+
|
|
141
|
+
// Keep highlight in range as items change.
|
|
142
|
+
const clampedHighlight = items.length === 0 ? 0 : Math.min(highlight, items.length - 1);
|
|
143
|
+
|
|
144
|
+
const activeArgHint = activeCommand ? argHint(activeCommand.args) : "";
|
|
145
|
+
|
|
146
|
+
// Build the context for a SPECIFIC command. The danger confirm() is bound to
|
|
147
|
+
// that command so the confirm bar names the command actually about to run —
|
|
148
|
+
// not whatever near-match happens to sit highlighted in the palette (e.g.
|
|
149
|
+
// typing "/clear"+Enter runs the destructive `clear`, but `clear-view` sorts
|
|
150
|
+
// first and would otherwise mislabel the bar as a harmless local-view reset).
|
|
151
|
+
const buildContext = useCallback(
|
|
152
|
+
(command: SlashCommand): CommandContext | null => {
|
|
153
|
+
if (!context) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
return { ...context, ...handlers, confirm: () => handlers.confirm(command) };
|
|
157
|
+
},
|
|
158
|
+
[context, handlers],
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const execute = useCallback(
|
|
162
|
+
async (command: SlashCommand, args: string[]): Promise<void> => {
|
|
163
|
+
const ctx = buildContext(command);
|
|
164
|
+
if (!ctx) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const result = await command.run(args, ctx);
|
|
169
|
+
if (result.message) {
|
|
170
|
+
ctx.notice({ tone: result.status === "ok" ? "ok" : "error", message: result.message });
|
|
171
|
+
}
|
|
172
|
+
if (result.status === "ok" && !result.keepDraft) {
|
|
173
|
+
setValue("");
|
|
174
|
+
}
|
|
175
|
+
} catch (cause) {
|
|
176
|
+
ctx.notice({ tone: "error", message: errorMessage(cause) });
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
[buildContext, setValue],
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const autocomplete = useCallback(
|
|
183
|
+
(command: SlashCommand) => {
|
|
184
|
+
setValue(`/${command.name} `);
|
|
185
|
+
setHighlight(0);
|
|
186
|
+
},
|
|
187
|
+
[setValue],
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const autocompleteHighlighted = useCallback(() => {
|
|
191
|
+
const command = items[clampedHighlight];
|
|
192
|
+
if (command) {
|
|
193
|
+
autocomplete(command);
|
|
194
|
+
}
|
|
195
|
+
}, [items, clampedHighlight, autocomplete]);
|
|
196
|
+
|
|
197
|
+
// Resolve a SPECIFIC, already-chosen command against the current draft, then
|
|
198
|
+
// either autocomplete (name-only / required arg missing) or execute. This is
|
|
199
|
+
// the shared core for both Enter (which first resolves WHICH command via the
|
|
200
|
+
// exact-match heuristic) and a pointer click (which has ALREADY chosen the
|
|
201
|
+
// command — the clicked row — and must not re-resolve to a near-match).
|
|
202
|
+
const runResolved = useCallback(
|
|
203
|
+
async (command: SlashCommand, options?: { explicit?: boolean }): Promise<void> => {
|
|
204
|
+
if (!parsed) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const explicit = options?.explicit ?? false;
|
|
208
|
+
// Token-vs-command equality is case-insensitive (matching the registry's
|
|
209
|
+
// matchCommand/filterCommands), so a fully-typed "/Clear" counts as having
|
|
210
|
+
// named `clear` and runs rather than autocompleting.
|
|
211
|
+
const nameMatchesToken =
|
|
212
|
+
command.name === parsed.name.toLowerCase() ||
|
|
213
|
+
(command.aliases?.includes(parsed.name.toLowerCase()) ?? false);
|
|
214
|
+
// A name-only token whose name doesn't yet equal the resolved command (e.g.
|
|
215
|
+
// "/cl" -> clear) first autocompletes so the operator sees the full name.
|
|
216
|
+
// An EXPLICIT pointer click skips this: the operator already chose the row,
|
|
217
|
+
// so clicking "/clear-view" (while the token is "clear") runs it outright
|
|
218
|
+
// rather than merely filling the name and waiting for a second Enter.
|
|
219
|
+
if (!explicit && !activeCommand && !nameMatchesToken && !parsed.hasTrailingSpace) {
|
|
220
|
+
autocomplete(command);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// When the click resolves a different command than the typed token, the
|
|
224
|
+
// typed token's tail isn't this command's args — run with no positional
|
|
225
|
+
// args and let the required-arg guard below prompt for them via autocomplete.
|
|
226
|
+
const args = nameMatchesToken || parsed.hasTrailingSpace ? parsed.args : [];
|
|
227
|
+
const missing = firstMissingRequiredArg(command, args);
|
|
228
|
+
if (missing) {
|
|
229
|
+
// A required arg is absent: keep the palette open at the arg hint rather
|
|
230
|
+
// than firing a half-formed command.
|
|
231
|
+
if (!parsed.hasTrailingSpace) {
|
|
232
|
+
autocomplete(command);
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
await execute(command, args);
|
|
237
|
+
},
|
|
238
|
+
[parsed, activeCommand, autocomplete, execute],
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const runHighlighted = useCallback(async (): Promise<void> => {
|
|
242
|
+
if (!parsed) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
// If the operator has arrow-navigated, Enter is an explicit choice of the
|
|
246
|
+
// highlighted row — run it directly, exactly like a click. This makes
|
|
247
|
+
// /clear-view reachable via ArrowDown+Enter even when "/clear" is fully
|
|
248
|
+
// typed (otherwise the exact-match override below would hijack it).
|
|
249
|
+
if (navigatedRef.current && !activeCommand) {
|
|
250
|
+
const highlighted = items[clampedHighlight];
|
|
251
|
+
if (highlighted) {
|
|
252
|
+
await runResolved(highlighted, { explicit: true });
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
// Otherwise (no explicit navigation): when the typed token is an exact
|
|
257
|
+
// command name (e.g. "/clear" while the longer "/clear-view" sits first in
|
|
258
|
+
// the filtered list), Enter should run THAT command, not autocomplete the
|
|
259
|
+
// highlighted near-match. Exact match wins over the highlight; otherwise use
|
|
260
|
+
// the highlighted row. A pointer click goes through runAt, never here.
|
|
261
|
+
// Compare case-insensitively, matching filterCommands/matchCommand, so a
|
|
262
|
+
// fully-typed "/Clear" still resolves to the exact (destructive) clear
|
|
263
|
+
// rather than the highlighted prefix near-match.
|
|
264
|
+
const token = parsed.name.toLowerCase();
|
|
265
|
+
const exact = items.find((item) => item.name === token || item.aliases?.includes(token));
|
|
266
|
+
const command = activeCommand ?? exact ?? items[clampedHighlight];
|
|
267
|
+
if (!command) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
await runResolved(command);
|
|
271
|
+
}, [parsed, activeCommand, items, clampedHighlight, runResolved]);
|
|
272
|
+
|
|
273
|
+
// Run the command at an EXPLICITLY chosen index (a pointer click on a palette
|
|
274
|
+
// row). Unlike runHighlighted this does NOT apply the exact-match override:
|
|
275
|
+
// clicking the harmless `/clear-view` row while the draft is "/clear" must run
|
|
276
|
+
// clear-view, never the destructive `/clear` that exact-matches the token. The
|
|
277
|
+
// operator's pointer is the selection; token-resolution heuristics don't apply.
|
|
278
|
+
const runAt = useCallback(
|
|
279
|
+
async (index: number): Promise<void> => {
|
|
280
|
+
const command = items[index];
|
|
281
|
+
if (!command) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
await runResolved(command, { explicit: true });
|
|
285
|
+
},
|
|
286
|
+
[items, runResolved],
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
// Track an in-flight run so Enter can't double-fire.
|
|
290
|
+
const runningRef = useRef(false);
|
|
291
|
+
|
|
292
|
+
const onKeyDown = useCallback(
|
|
293
|
+
(event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
|
|
294
|
+
if (!open) {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
switch (event.key) {
|
|
298
|
+
case "ArrowDown": {
|
|
299
|
+
event.preventDefault();
|
|
300
|
+
navigatedRef.current = true;
|
|
301
|
+
setHighlight((current) => (items.length === 0 ? 0 : (Math.min(current, items.length - 1) + 1) % items.length));
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
case "ArrowUp": {
|
|
305
|
+
event.preventDefault();
|
|
306
|
+
navigatedRef.current = true;
|
|
307
|
+
setHighlight((current) => {
|
|
308
|
+
const base = Math.min(current, items.length - 1);
|
|
309
|
+
return items.length === 0 ? 0 : (base - 1 + items.length) % items.length;
|
|
310
|
+
});
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
case "Tab": {
|
|
314
|
+
event.preventDefault();
|
|
315
|
+
autocompleteHighlighted();
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
case "Enter": {
|
|
319
|
+
if (event.shiftKey || event.nativeEvent?.isComposing) {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
event.preventDefault();
|
|
323
|
+
if (runningRef.current) {
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
runningRef.current = true;
|
|
327
|
+
void runHighlighted().finally(() => {
|
|
328
|
+
runningRef.current = false;
|
|
329
|
+
});
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
case "Escape": {
|
|
333
|
+
event.preventDefault();
|
|
334
|
+
// Close the palette but keep the draft intact. Remember the dismissed
|
|
335
|
+
// value; the next edit re-opens (value !== dismissedValue).
|
|
336
|
+
setDismissedValue(value);
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
default:
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
[open, items, autocompleteHighlighted, runHighlighted, value],
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
open,
|
|
348
|
+
isCommandDraft,
|
|
349
|
+
items,
|
|
350
|
+
highlight: clampedHighlight,
|
|
351
|
+
setHighlight,
|
|
352
|
+
activeCommand,
|
|
353
|
+
activeArgHint,
|
|
354
|
+
onKeyDown,
|
|
355
|
+
runHighlighted,
|
|
356
|
+
runAt,
|
|
357
|
+
autocompleteHighlighted,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function errorMessage(cause: unknown): string {
|
|
362
|
+
if (cause instanceof Error) {
|
|
363
|
+
return cause.message;
|
|
364
|
+
}
|
|
365
|
+
return String(cause);
|
|
366
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import type { SessionEvent, SessionTurn, UpdateSessionTurnRequest } from "@opengeni/sdk";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
4
|
+
import { useDebouncedCallback, useMutationRunner, useSessionEventTrigger, type SessionEventFeedOptions } from "./internal";
|
|
5
|
+
|
|
6
|
+
/** Event types that change the turn queue (queue/edit/reorder/claim/finish). */
|
|
7
|
+
export function isTurnQueueEvent(event: Pick<SessionEvent, "type">): boolean {
|
|
8
|
+
return event.type.startsWith("turn.");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Queued turns in execution order (position, then creation time). */
|
|
12
|
+
export function queueFromTurns(turns: SessionTurn[]): SessionTurn[] {
|
|
13
|
+
return turns
|
|
14
|
+
.filter((turn) => turn.status === "queued")
|
|
15
|
+
.sort((a, b) => a.position - b.position || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The turn currently holding the session (running or awaiting approval). */
|
|
19
|
+
export function activeTurnFromTurns(turns: SessionTurn[]): SessionTurn | null {
|
|
20
|
+
return turns.find((turn) => turn.status === "running" || turn.status === "requires_action") ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Optimistic projection of a queued-turn edit. */
|
|
24
|
+
export function applyTurnEdit(turns: SessionTurn[], turnId: string, update: UpdateSessionTurnRequest): SessionTurn[] {
|
|
25
|
+
return turns.map((turn) => {
|
|
26
|
+
if (turn.id !== turnId || turn.status !== "queued") {
|
|
27
|
+
return turn;
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
...turn,
|
|
31
|
+
...(update.prompt !== undefined ? { prompt: update.prompt } : {}),
|
|
32
|
+
...(update.resources !== undefined ? { resources: update.resources } : {}),
|
|
33
|
+
...(update.tools !== undefined ? { tools: update.tools } : {}),
|
|
34
|
+
...(update.model !== undefined ? { model: update.model } : {}),
|
|
35
|
+
...(update.reasoningEffort !== undefined ? { reasoningEffort: update.reasoningEffort } : {}),
|
|
36
|
+
...(update.sandboxBackend !== undefined ? { sandboxBackend: update.sandboxBackend } : {}),
|
|
37
|
+
...(update.metadata !== undefined ? { metadata: update.metadata } : {}),
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Optimistic projection of a reorder, mirroring the server: the listed
|
|
44
|
+
* queued turns get positions 1..n in the given order; everything else keeps
|
|
45
|
+
* its position.
|
|
46
|
+
*/
|
|
47
|
+
export function applyTurnReorder(turns: SessionTurn[], turnIds: string[]): SessionTurn[] {
|
|
48
|
+
const positions = new Map(turnIds.map((turnId, index) => [turnId, index + 1] as const));
|
|
49
|
+
return turns.map((turn) => {
|
|
50
|
+
const position = positions.get(turn.id);
|
|
51
|
+
return position !== undefined && turn.status === "queued" ? { ...turn, position } : turn;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Optimistic projection of a queued-turn delete (server marks it cancelled). */
|
|
56
|
+
export function applyTurnRemoval(turns: SessionTurn[], turnId: string): SessionTurn[] {
|
|
57
|
+
return turns.map((turn) => (turn.id === turnId && turn.status === "queued" ? { ...turn, status: "cancelled" as const } : turn));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type UseTurnQueueOptions = ClientOverride & SessionEventFeedOptions & {
|
|
61
|
+
/** Optional safety-net polling (ms). Off by default — turn.* events drive updates. */
|
|
62
|
+
pollIntervalMs?: number | undefined;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type UseTurnQueueResult = {
|
|
66
|
+
/** All turns the API returned (history + queue), newest server view. */
|
|
67
|
+
turns: SessionTurn[];
|
|
68
|
+
/** Queued turns in execution order — render this as the editable queue. */
|
|
69
|
+
queue: SessionTurn[];
|
|
70
|
+
/** The running / requires_action turn, if any. */
|
|
71
|
+
activeTurn: SessionTurn | null;
|
|
72
|
+
loading: boolean;
|
|
73
|
+
error: Error | null;
|
|
74
|
+
refresh: () => Promise<void>;
|
|
75
|
+
/** Edit a queued turn (optimistic; rolls back via refetch on failure). */
|
|
76
|
+
editTurn: (turnId: string, update: UpdateSessionTurnRequest) => Promise<SessionTurn | null>;
|
|
77
|
+
/** Reorder the queue to the given queued-turn id order (optimistic). */
|
|
78
|
+
reorderTurns: (turnIds: string[]) => Promise<SessionTurn[] | null>;
|
|
79
|
+
/** Delete (cancel) a queued turn before it is claimed (optimistic). */
|
|
80
|
+
removeTurn: (turnId: string) => Promise<SessionTurn | null>;
|
|
81
|
+
/** True while an edit/reorder/remove is in flight. */
|
|
82
|
+
mutating: boolean;
|
|
83
|
+
/** Last failed mutation, until the next mutation or clear. */
|
|
84
|
+
mutationError: Error | null;
|
|
85
|
+
clearMutationError: () => void;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The live turn queue — the heart of the queue-by-default interaction model.
|
|
90
|
+
* Messages sent mid-turn stack up here, visible and editable/reorderable/
|
|
91
|
+
* deletable until the worker claims them. Updates arrive over the session
|
|
92
|
+
* event stream (`turn.*`), either shared via `options.events` (pass the log
|
|
93
|
+
* from `useSessionEvents` to reuse its connection) or a dedicated tail
|
|
94
|
+
* stream. All mutations apply optimistically and reconcile with the server.
|
|
95
|
+
*/
|
|
96
|
+
export function useTurnQueue(sessionId: string | null | undefined, options: UseTurnQueueOptions = {}): UseTurnQueueResult {
|
|
97
|
+
const { client, workspaceId } = useOpenGeni(options);
|
|
98
|
+
const enabled = (options.enabled ?? true) && Boolean(sessionId);
|
|
99
|
+
const [turns, setTurns] = useState<SessionTurn[]>([]);
|
|
100
|
+
const [loading, setLoading] = useState(enabled);
|
|
101
|
+
const [error, setError] = useState<Error | null>(null);
|
|
102
|
+
const mutation = useMutationRunner();
|
|
103
|
+
const generation = useRef(0);
|
|
104
|
+
const targetKeyRef = useRef<string | null>(null);
|
|
105
|
+
|
|
106
|
+
const load = useCallback(async (): Promise<void> => {
|
|
107
|
+
if (!sessionId) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const ticket = ++generation.current;
|
|
111
|
+
try {
|
|
112
|
+
const fetched = await client.listTurns(workspaceId, sessionId);
|
|
113
|
+
if (ticket === generation.current) {
|
|
114
|
+
setTurns(fetched);
|
|
115
|
+
setError(null);
|
|
116
|
+
setLoading(false);
|
|
117
|
+
}
|
|
118
|
+
} catch (cause) {
|
|
119
|
+
if (ticket === generation.current) {
|
|
120
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
121
|
+
setLoading(false);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}, [client, workspaceId, sessionId]);
|
|
125
|
+
|
|
126
|
+
// Reset when the target session changes; initial load + optional polling.
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
const targetKey = `${workspaceId}\u0000${sessionId ?? ""}`;
|
|
129
|
+
if (targetKeyRef.current !== targetKey) {
|
|
130
|
+
targetKeyRef.current = targetKey;
|
|
131
|
+
setTurns([]);
|
|
132
|
+
setError(null);
|
|
133
|
+
}
|
|
134
|
+
if (!enabled) {
|
|
135
|
+
setLoading(false);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
setLoading(true);
|
|
139
|
+
void load();
|
|
140
|
+
const pollIntervalMs = options.pollIntervalMs;
|
|
141
|
+
if (pollIntervalMs === undefined || pollIntervalMs <= 0) {
|
|
142
|
+
return () => {
|
|
143
|
+
generation.current += 1;
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const timer = setInterval(() => void load(), pollIntervalMs);
|
|
147
|
+
return () => {
|
|
148
|
+
clearInterval(timer);
|
|
149
|
+
generation.current += 1;
|
|
150
|
+
};
|
|
151
|
+
}, [load, enabled, workspaceId, sessionId, options.pollIntervalMs]);
|
|
152
|
+
|
|
153
|
+
// Live updates: any turn.* event re-syncs the queue (debounced).
|
|
154
|
+
const scheduleRefresh = useDebouncedCallback(() => void load());
|
|
155
|
+
useSessionEventTrigger(client, workspaceId, sessionId, isTurnQueueEvent, scheduleRefresh, {
|
|
156
|
+
enabled,
|
|
157
|
+
...(options.events !== undefined ? { events: options.events } : {}),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const editTurn = useCallback(
|
|
161
|
+
async (turnId: string, update: UpdateSessionTurnRequest): Promise<SessionTurn | null> => {
|
|
162
|
+
if (!sessionId) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
setTurns((current) => applyTurnEdit(current, turnId, update));
|
|
166
|
+
const result = await mutation.run(() => client.updateQueuedTurn(workspaceId, sessionId, turnId, update));
|
|
167
|
+
if (result) {
|
|
168
|
+
setTurns((current) => current.map((turn) => (turn.id === result.id ? result : turn)));
|
|
169
|
+
} else {
|
|
170
|
+
void load();
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
173
|
+
},
|
|
174
|
+
[client, workspaceId, sessionId, mutation.run, load],
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const reorderTurns = useCallback(
|
|
178
|
+
async (turnIds: string[]): Promise<SessionTurn[] | null> => {
|
|
179
|
+
if (!sessionId || turnIds.length === 0) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
setTurns((current) => applyTurnReorder(current, turnIds));
|
|
183
|
+
const result = await mutation.run(() => client.reorderQueuedTurns(workspaceId, sessionId, turnIds));
|
|
184
|
+
if (result) {
|
|
185
|
+
// The server returns the queued turns; merge them over local state.
|
|
186
|
+
setTurns((current) => {
|
|
187
|
+
const bySId = new Map(result.map((turn) => [turn.id, turn] as const));
|
|
188
|
+
return current.map((turn) => bySId.get(turn.id) ?? turn);
|
|
189
|
+
});
|
|
190
|
+
} else {
|
|
191
|
+
void load();
|
|
192
|
+
}
|
|
193
|
+
return result;
|
|
194
|
+
},
|
|
195
|
+
[client, workspaceId, sessionId, mutation.run, load],
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const removeTurn = useCallback(
|
|
199
|
+
async (turnId: string): Promise<SessionTurn | null> => {
|
|
200
|
+
if (!sessionId) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
setTurns((current) => applyTurnRemoval(current, turnId));
|
|
204
|
+
const result = await mutation.run(() => client.deleteQueuedTurn(workspaceId, sessionId, turnId));
|
|
205
|
+
if (result) {
|
|
206
|
+
setTurns((current) => current.map((turn) => (turn.id === result.id ? result : turn)));
|
|
207
|
+
} else {
|
|
208
|
+
void load();
|
|
209
|
+
}
|
|
210
|
+
return result;
|
|
211
|
+
},
|
|
212
|
+
[client, workspaceId, sessionId, mutation.run, load],
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
turns,
|
|
217
|
+
queue: queueFromTurns(turns),
|
|
218
|
+
activeTurn: activeTurnFromTurns(turns),
|
|
219
|
+
loading,
|
|
220
|
+
error,
|
|
221
|
+
refresh: load,
|
|
222
|
+
editTurn,
|
|
223
|
+
reorderTurns,
|
|
224
|
+
removeTurn,
|
|
225
|
+
mutating: mutation.mutating,
|
|
226
|
+
mutationError: mutation.mutationError,
|
|
227
|
+
clearMutationError: mutation.clearMutationError,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Session } from "@opengeni/sdk";
|
|
2
|
+
import { useCallback } from "react";
|
|
3
|
+
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
4
|
+
import { usePolledValue } from "./internal";
|
|
5
|
+
|
|
6
|
+
export type UseWorkspaceSessionsOptions = ClientOverride & {
|
|
7
|
+
limit?: number | undefined;
|
|
8
|
+
/** Refresh interval (ms) for fleet/manager views. Off by default. */
|
|
9
|
+
pollIntervalMs?: number | undefined;
|
|
10
|
+
enabled?: boolean | undefined;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type UseWorkspaceSessionsResult = {
|
|
14
|
+
sessions: Session[];
|
|
15
|
+
loading: boolean;
|
|
16
|
+
error: Error | null;
|
|
17
|
+
refresh: () => Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** List the workspace's sessions — the data behind fleet and manager views. */
|
|
21
|
+
export function useWorkspaceSessions(options: UseWorkspaceSessionsOptions = {}): UseWorkspaceSessionsResult {
|
|
22
|
+
const { client, workspaceId } = useOpenGeni(options);
|
|
23
|
+
const limit = options.limit;
|
|
24
|
+
const load = useCallback(
|
|
25
|
+
async () => await client.listSessions(workspaceId, limit !== undefined ? { limit } : {}),
|
|
26
|
+
[client, workspaceId, limit],
|
|
27
|
+
);
|
|
28
|
+
const state = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled: options.enabled });
|
|
29
|
+
return { sessions: state.data ?? [], loading: state.loading, error: state.error, refresh: state.refresh };
|
|
30
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { CreateWorkspaceRequest, UpdateWorkspaceRequest, Workspace } from "@opengeni/sdk";
|
|
2
|
+
import { useCallback } from "react";
|
|
3
|
+
import { useOpenGeniClient, type ClientOverride } from "../provider";
|
|
4
|
+
import { useMutationRunner, usePolledValue } from "./internal";
|
|
5
|
+
|
|
6
|
+
export type UseWorkspacesOptions = Pick<ClientOverride, "client"> & {
|
|
7
|
+
pollIntervalMs?: number | undefined;
|
|
8
|
+
enabled?: boolean | undefined;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type UseWorkspacesResult = {
|
|
12
|
+
workspaces: Workspace[];
|
|
13
|
+
loading: boolean;
|
|
14
|
+
error: Error | null;
|
|
15
|
+
refresh: () => Promise<void>;
|
|
16
|
+
create: (request: CreateWorkspaceRequest) => Promise<Workspace | null>;
|
|
17
|
+
update: (workspaceId: string, request: UpdateWorkspaceRequest) => Promise<Workspace | null>;
|
|
18
|
+
mutating: boolean;
|
|
19
|
+
mutationError: Error | null;
|
|
20
|
+
clearMutationError: () => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The caller's workspaces (workspace switchers, onboarding). Not scoped to
|
|
25
|
+
* the provider's workspace, so it only needs the client.
|
|
26
|
+
*/
|
|
27
|
+
export function useWorkspaces(options: UseWorkspacesOptions = {}): UseWorkspacesResult {
|
|
28
|
+
const client = useOpenGeniClient(options);
|
|
29
|
+
const load = useCallback(async () => await client.listWorkspaces(), [client]);
|
|
30
|
+
const state = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled: options.enabled });
|
|
31
|
+
const mutation = useMutationRunner();
|
|
32
|
+
|
|
33
|
+
const create = useCallback(
|
|
34
|
+
async (request: CreateWorkspaceRequest): Promise<Workspace | null> => {
|
|
35
|
+
const result = await mutation.run(() => client.createWorkspace(request));
|
|
36
|
+
if (result) {
|
|
37
|
+
await state.refresh();
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
},
|
|
41
|
+
[client, mutation.run, state.refresh],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const update = useCallback(
|
|
45
|
+
async (workspaceId: string, request: UpdateWorkspaceRequest): Promise<Workspace | null> => {
|
|
46
|
+
const result = await mutation.run(() => client.updateWorkspace(workspaceId, request));
|
|
47
|
+
if (result) {
|
|
48
|
+
await state.refresh();
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
},
|
|
52
|
+
[client, mutation.run, state.refresh],
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
workspaces: state.data ?? [],
|
|
57
|
+
loading: state.loading,
|
|
58
|
+
error: state.error,
|
|
59
|
+
refresh: state.refresh,
|
|
60
|
+
create,
|
|
61
|
+
update,
|
|
62
|
+
mutating: mutation.mutating,
|
|
63
|
+
mutationError: mutation.mutationError,
|
|
64
|
+
clearMutationError: mutation.clearMutationError,
|
|
65
|
+
};
|
|
66
|
+
}
|