@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.
@@ -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
+ }