@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,236 @@
1
+ import type { SessionEvent } from "@opengeni/sdk";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import type { SessionClientLike } from "../client";
4
+
5
+ export type AsyncListState<T> = {
6
+ data: T | null;
7
+ loading: boolean;
8
+ error: Error | null;
9
+ refresh: () => Promise<void>;
10
+ };
11
+
12
+ /**
13
+ * Shared fetch + optional polling loop for the list/read hooks. Stale
14
+ * responses (superseded by a newer load or an unmount) are dropped.
15
+ */
16
+ export function usePolledValue<T>(load: () => Promise<T>, options: { pollIntervalMs?: number | undefined; enabled?: boolean | undefined } = {}): AsyncListState<T> {
17
+ const enabled = options.enabled ?? true;
18
+ const pollIntervalMs = options.pollIntervalMs;
19
+ const [data, setData] = useState<T | null>(null);
20
+ const [loading, setLoading] = useState(enabled);
21
+ const [error, setError] = useState<Error | null>(null);
22
+ const generation = useRef(0);
23
+ const loadRef = useRef(load);
24
+
25
+ // A new loader identity means a new query (different session/workspace/...):
26
+ // drop the previous result instead of showing it as the new query's data.
27
+ useEffect(() => {
28
+ if (loadRef.current !== load) {
29
+ loadRef.current = load;
30
+ setData(null);
31
+ setError(null);
32
+ }
33
+ }, [load]);
34
+
35
+ const run = useCallback(async () => {
36
+ const ticket = ++generation.current;
37
+ try {
38
+ const result = await load();
39
+ if (ticket === generation.current) {
40
+ setData(result);
41
+ setError(null);
42
+ setLoading(false);
43
+ }
44
+ } catch (cause) {
45
+ if (ticket === generation.current) {
46
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
47
+ setLoading(false);
48
+ }
49
+ }
50
+ }, [load]);
51
+
52
+ useEffect(() => {
53
+ if (!enabled) {
54
+ setLoading(false);
55
+ return;
56
+ }
57
+ setLoading(true);
58
+ void run();
59
+ if (pollIntervalMs === undefined || pollIntervalMs <= 0) {
60
+ return () => {
61
+ generation.current += 1;
62
+ };
63
+ }
64
+ const timer = setInterval(() => void run(), pollIntervalMs);
65
+ return () => {
66
+ clearInterval(timer);
67
+ generation.current += 1;
68
+ };
69
+ }, [run, enabled, pollIntervalMs]);
70
+
71
+ return { data, loading, error, refresh: run };
72
+ }
73
+
74
+ export type MutationState = {
75
+ mutating: boolean;
76
+ mutationError: Error | null;
77
+ clearMutationError: () => void;
78
+ };
79
+
80
+ /**
81
+ * Shared async mutation runner for the write hooks. `run` resolves with the
82
+ * operation's value, or `null` after capturing the error in `mutationError`
83
+ * (callers then roll back optimistic state).
84
+ */
85
+ export function useMutationRunner(): MutationState & {
86
+ run: <T>(operation: () => Promise<T>) => Promise<T | null>;
87
+ } {
88
+ const [mutating, setMutating] = useState(false);
89
+ const [mutationError, setMutationError] = useState<Error | null>(null);
90
+ const inFlight = useRef(0);
91
+ const mounted = useRef(true);
92
+ useEffect(() => {
93
+ mounted.current = true;
94
+ return () => {
95
+ mounted.current = false;
96
+ };
97
+ }, []);
98
+ const run = useCallback(async <T,>(operation: () => Promise<T>): Promise<T | null> => {
99
+ inFlight.current += 1;
100
+ if (mounted.current) {
101
+ setMutating(true);
102
+ setMutationError(null);
103
+ }
104
+ try {
105
+ return await operation();
106
+ } catch (cause) {
107
+ if (mounted.current) {
108
+ setMutationError(cause instanceof Error ? cause : new Error(String(cause)));
109
+ }
110
+ return null;
111
+ } finally {
112
+ inFlight.current -= 1;
113
+ if (mounted.current && inFlight.current === 0) {
114
+ setMutating(false);
115
+ }
116
+ }
117
+ }, []);
118
+ return {
119
+ mutating,
120
+ mutationError,
121
+ clearMutationError: useCallback(() => setMutationError(null), []),
122
+ run,
123
+ };
124
+ }
125
+
126
+ export type SessionEventFeedOptions = {
127
+ /**
128
+ * Share an existing event log (from `useSessionEvents`) instead of opening
129
+ * a second stream. When omitted the hook tails the session's event stream
130
+ * itself, starting at the current `lastSequence`.
131
+ */
132
+ events?: SessionEvent[] | undefined;
133
+ enabled?: boolean | undefined;
134
+ };
135
+
136
+ /**
137
+ * Invoke `onEvent` for every session event matching `match` — the live-update
138
+ * primitive behind `useTurnQueue` and `useGoal`. Either watches a shared
139
+ * `events` log or tails the stream directly (reconnect handled by the SDK).
140
+ */
141
+ export function useSessionEventTrigger(
142
+ client: SessionClientLike,
143
+ workspaceId: string,
144
+ sessionId: string | null | undefined,
145
+ match: (event: SessionEvent) => boolean,
146
+ onEvent: (event: SessionEvent) => void,
147
+ options: SessionEventFeedOptions = {},
148
+ ): void {
149
+ const enabled = options.enabled ?? true;
150
+ const events = options.events;
151
+ const sharedFeed = events !== undefined;
152
+ const matchRef = useRef(match);
153
+ matchRef.current = match;
154
+ const onEventRef = useRef(onEvent);
155
+ onEventRef.current = onEvent;
156
+ const consumedRef = useRef(0);
157
+ const feedKeyRef = useRef<string | null>(null);
158
+
159
+ // Shared-log mode: scan only the unseen tail on every append.
160
+ useEffect(() => {
161
+ if (!sharedFeed || !enabled || !sessionId) {
162
+ return;
163
+ }
164
+ const feedKey = `${workspaceId}\u0000${sessionId}`;
165
+ const firstSequence = events[0]?.sequence ?? 0;
166
+ // A new session target or a log reset (sequence restarted below the
167
+ // cursor) restarts consumption from the top of the shared log.
168
+ if (feedKeyRef.current !== feedKey || firstSequence > consumedRef.current + 1) {
169
+ feedKeyRef.current = feedKey;
170
+ consumedRef.current = 0;
171
+ }
172
+ for (const event of events) {
173
+ if (event.sequence <= consumedRef.current) {
174
+ continue;
175
+ }
176
+ consumedRef.current = event.sequence;
177
+ if (matchRef.current(event)) {
178
+ onEventRef.current(event);
179
+ }
180
+ }
181
+ }, [sharedFeed, enabled, events, workspaceId, sessionId]);
182
+
183
+ // Self-stream mode: tail from the session's current lastSequence.
184
+ useEffect(() => {
185
+ if (sharedFeed || !enabled || !sessionId) {
186
+ return;
187
+ }
188
+ const controller = new AbortController();
189
+ void (async () => {
190
+ try {
191
+ const session = await client.getSession(workspaceId, sessionId);
192
+ if (controller.signal.aborted) {
193
+ return;
194
+ }
195
+ const stream = client.streamEvents(workspaceId, sessionId, {
196
+ after: session.lastSequence,
197
+ signal: controller.signal,
198
+ });
199
+ for await (const event of stream) {
200
+ if (matchRef.current(event)) {
201
+ onEventRef.current(event);
202
+ }
203
+ }
204
+ } catch {
205
+ // Live updates are best-effort: the read hooks still expose refresh()
206
+ // and the initial load already populated state.
207
+ }
208
+ })();
209
+ return () => {
210
+ controller.abort();
211
+ };
212
+ }, [sharedFeed, enabled, client, workspaceId, sessionId]);
213
+ }
214
+
215
+ /** Debounce rapid event bursts into one trailing call (default 150ms). */
216
+ export function useDebouncedCallback(callback: () => void, delayMs = 150): () => void {
217
+ const callbackRef = useRef(callback);
218
+ callbackRef.current = callback;
219
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
220
+ useEffect(() => {
221
+ return () => {
222
+ if (timerRef.current !== null) {
223
+ clearTimeout(timerRef.current);
224
+ }
225
+ };
226
+ }, []);
227
+ return useCallback(() => {
228
+ if (timerRef.current !== null) {
229
+ clearTimeout(timerRef.current);
230
+ }
231
+ timerRef.current = setTimeout(() => {
232
+ timerRef.current = null;
233
+ callbackRef.current();
234
+ }, delayMs);
235
+ }, [delayMs]);
236
+ }
@@ -0,0 +1,51 @@
1
+ import type { BillingBalance, UsageEvent } from "@opengeni/sdk";
2
+ import { useCallback } from "react";
3
+ import { useOpenGeniClient, type ClientOverride } from "../provider";
4
+ import { usePolledValue } from "./internal";
5
+
6
+ export type UseBillingUsageOptions = Pick<ClientOverride, "client"> & {
7
+ /** Account to read. Defaults to the caller's default account server-side. */
8
+ accountId?: string | undefined;
9
+ /** Filter usage to one workspace. */
10
+ workspaceId?: string | undefined;
11
+ /** Refresh interval (ms) for live billing meters. Off by default. */
12
+ pollIntervalMs?: number | undefined;
13
+ enabled?: boolean | undefined;
14
+ };
15
+
16
+ export type UseBillingUsageResult = {
17
+ /** Prepaid credit balance (micro-USD), null until loaded. */
18
+ balance: BillingBalance | null;
19
+ /** Recent usage events (runs, tokens, cost, uploads, ...). */
20
+ usage: UsageEvent[];
21
+ loading: boolean;
22
+ error: Error | null;
23
+ refresh: () => Promise<void>;
24
+ };
25
+
26
+ /**
27
+ * Account billing usage: credit balance + recent usage events — the data
28
+ * behind per-call billing meters. Account-scoped, so it only needs the
29
+ * client; pass `workspaceId` to narrow usage to one workspace.
30
+ */
31
+ export function useBillingUsage(options: UseBillingUsageOptions = {}): UseBillingUsageResult {
32
+ const client = useOpenGeniClient(options);
33
+ const accountId = options.accountId;
34
+ const workspaceId = options.workspaceId;
35
+ const load = useCallback(
36
+ async () =>
37
+ await client.getBillingUsage({
38
+ ...(accountId !== undefined ? { accountId } : {}),
39
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
40
+ }),
41
+ [client, accountId, workspaceId],
42
+ );
43
+ const state = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled: options.enabled });
44
+ return {
45
+ balance: state.data?.balance ?? null,
46
+ usage: state.data?.usage ?? [],
47
+ loading: state.loading,
48
+ error: state.error,
49
+ refresh: state.refresh,
50
+ };
51
+ }
@@ -0,0 +1,213 @@
1
+ import type { SendMessageInput } from "@opengeni/sdk";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useOpenGeni, type ClientOverride } from "../provider";
4
+
5
+ export type ComposerSendExtras = Omit<SendMessageInput, "text" | "clientEventId">;
6
+
7
+ /**
8
+ * Compose-time delivery choice. `queue` (the default) stacks the message
9
+ * behind the running turn — visible, editable, reorderable until claimed.
10
+ * `steer` interrupts the running turn and injects the message now.
11
+ */
12
+ export type ComposerMode = "queue" | "steer";
13
+
14
+ export type UseComposerOptions = ClientOverride & {
15
+ /** Called with the accepted text after a successful send. */
16
+ onSent?: ((text: string) => void) | undefined;
17
+ /**
18
+ * Extra message fields (resources, tools, model, reasoningEffort) merged
19
+ * into every send. A function is evaluated at send time so it can read the
20
+ * surrounding UI state (attachment pickers, model selectors, ...).
21
+ */
22
+ sendExtras?: ComposerSendExtras | (() => ComposerSendExtras) | undefined;
23
+ /** Initial delivery mode. Defaults to `"queue"`. */
24
+ defaultMode?: ComposerMode | undefined;
25
+ };
26
+
27
+ export type ComposerState = {
28
+ value: string;
29
+ setValue: (value: string) => void;
30
+ /** Send the draft (or an explicit text) using the current mode. */
31
+ send: (text?: string) => Promise<boolean>;
32
+ sending: boolean;
33
+ canSend: boolean;
34
+ /** Queue (default) vs steer — the compose-time delivery choice. */
35
+ mode: ComposerMode;
36
+ setMode: (mode: ComposerMode) => void;
37
+ /** Ask the agent to stop the current turn. */
38
+ interrupt: (reason?: string) => Promise<void>;
39
+ interrupting: boolean;
40
+ error: Error | null;
41
+ clearError: () => void;
42
+ };
43
+
44
+ /**
45
+ * Draft + send + interrupt state for the chat composer — the only
46
+ * human-to-agent input surface. The draft survives a failed send (nothing is
47
+ * more hostile than losing a typed message); each send carries a generated
48
+ * `clientEventId` so retries stay idempotent server-side.
49
+ */
50
+ export function useComposer(sessionId: string | null | undefined, options: UseComposerOptions = {}): ComposerState {
51
+ const { client, workspaceId } = useOpenGeni(options);
52
+ const defaultMode = options.defaultMode ?? "queue";
53
+ const [value, setValue] = useState("");
54
+ const [mode, setMode] = useState<ComposerMode>(defaultMode);
55
+ const [sending, setSending] = useState(false);
56
+ const [interrupting, setInterrupting] = useState(false);
57
+ const [error, setError] = useState<Error | null>(null);
58
+ const pendingClientEventId = useRef<string | null>(null);
59
+ const onSent = options.onSent;
60
+ // Read at send time so an in-flight send uses the mode chosen at submit.
61
+ const modeRef = useRef(mode);
62
+ modeRef.current = mode;
63
+ // Read through a ref so a new extras closure (created every render by
64
+ // callers passing inline functions) does not invalidate `send`.
65
+ const sendExtrasRef = useRef(options.sendExtras);
66
+ sendExtrasRef.current = options.sendExtras;
67
+
68
+ // A composer is bound to one session: switching targets must not leak the
69
+ // previous session's draft, error, or retry idempotency key.
70
+ const targetKey = `${workspaceId}\u0000${sessionId ?? ""}`;
71
+ const targetKeyRef = useRef(targetKey);
72
+ useEffect(() => {
73
+ if (targetKeyRef.current !== targetKey) {
74
+ targetKeyRef.current = targetKey;
75
+ pendingClientEventId.current = null;
76
+ setValue("");
77
+ setError(null);
78
+ setMode(defaultMode);
79
+ }
80
+ }, [targetKey, defaultMode]);
81
+
82
+ const send = useCallback(
83
+ async (explicit?: string): Promise<boolean> => {
84
+ const draftAtSend = value;
85
+ const text = (explicit ?? draftAtSend).trim();
86
+ // Resolve the extras once: a file-only message (empty text + ≥1 ready
87
+ // resource) is legitimate, so we must not bail on empty text alone.
88
+ const extras = resolveSendExtras(sendExtrasRef.current);
89
+ const hasResources = (extras.resources?.length ?? 0) > 0;
90
+ if ((!text && !hasResources) || !sessionId || sending) {
91
+ return false;
92
+ }
93
+ // Reuse the clientEventId across retries of the same draft so a
94
+ // timeout + resend cannot double-deliver the message.
95
+ pendingClientEventId.current ??= generateClientEventId();
96
+ setSending(true);
97
+ setError(null);
98
+ try {
99
+ // The wire contract requires non-empty text (z.string().min(1)) and the
100
+ // worker rejects whitespace-only text; a file-only message therefore
101
+ // carries a minimal default so the attachments still get delivered.
102
+ const sendText = text || FILE_ONLY_MESSAGE_TEXT;
103
+ const input = composeSendInput(sendText, pendingClientEventId.current, extras);
104
+ if (modeRef.current === "steer") {
105
+ await client.steerMessage(workspaceId, sessionId, input);
106
+ } else {
107
+ await client.sendMessage(workspaceId, sessionId, input);
108
+ }
109
+ pendingClientEventId.current = null;
110
+ if (explicit === undefined) {
111
+ // Clear only the draft that was sent: edits made while the request
112
+ // was in flight were never delivered and must survive.
113
+ setValue((current) => (current === draftAtSend ? "" : current));
114
+ }
115
+ onSent?.(sendText);
116
+ return true;
117
+ } catch (cause) {
118
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
119
+ return false;
120
+ } finally {
121
+ setSending(false);
122
+ }
123
+ },
124
+ [client, workspaceId, sessionId, value, sending, onSent],
125
+ );
126
+
127
+ // A send is possible with non-empty text OR with ≥1 attached resource (a
128
+ // file-only message). Resources ride in `sendExtras`, so we resolve them here
129
+ // — keeping useComposer attachment-agnostic while still lighting up the send
130
+ // affordance the moment a file is ready. ChatComposer additionally gates this
131
+ // on its `attachments.uploading` flag so a message never departs mid-upload.
132
+ const hasReadyResources = (resolveSendExtras(sendExtrasRef.current).resources?.length ?? 0) > 0;
133
+
134
+ const interrupt = useCallback(
135
+ async (reason?: string): Promise<void> => {
136
+ if (!sessionId || interrupting) {
137
+ return;
138
+ }
139
+ setInterrupting(true);
140
+ setError(null);
141
+ try {
142
+ await client.interrupt(workspaceId, sessionId, reason !== undefined ? { reason } : {});
143
+ } catch (cause) {
144
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
145
+ } finally {
146
+ setInterrupting(false);
147
+ }
148
+ },
149
+ [client, workspaceId, sessionId, interrupting],
150
+ );
151
+
152
+ const updateValue = useCallback((next: string) => {
153
+ pendingClientEventId.current = null;
154
+ setValue(next);
155
+ }, []);
156
+
157
+ return {
158
+ value,
159
+ setValue: updateValue,
160
+ send,
161
+ sending,
162
+ canSend: Boolean(sessionId) && !sending && (value.trim().length > 0 || hasReadyResources),
163
+ mode,
164
+ setMode,
165
+ interrupt,
166
+ interrupting,
167
+ error,
168
+ clearError: useCallback(() => setError(null), []),
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Default text for a file-only message (attachment(s) present, no typed draft).
174
+ * Kept non-empty so the wire contract (`text: z.string().min(1)`) and the
175
+ * worker's non-whitespace guard accept it; the attached files still ride in
176
+ * `resources`. Exported for tests.
177
+ */
178
+ export const FILE_ONLY_MESSAGE_TEXT = "(see attached files)";
179
+
180
+ /** Resolve possibly-deferred extras to a concrete bag (function evaluated now). */
181
+ export function resolveSendExtras(
182
+ extras: ComposerSendExtras | (() => ComposerSendExtras) | undefined,
183
+ ): ComposerSendExtras {
184
+ return (typeof extras === "function" ? extras() : extras) ?? {};
185
+ }
186
+
187
+ /**
188
+ * Merge the draft text + idempotency key with caller-provided extras. The
189
+ * text and clientEventId always win over extras. Exported for tests.
190
+ */
191
+ export function composeSendInput(
192
+ text: string,
193
+ clientEventId: string,
194
+ extras: ComposerSendExtras | (() => ComposerSendExtras) | undefined,
195
+ ): SendMessageInput {
196
+ return { ...resolveSendExtras(extras), text, clientEventId };
197
+ }
198
+
199
+ /** Submit on plain Enter; Shift+Enter inserts a newline. Exported for tests. */
200
+ export function shouldSubmitOnKey(event: { key: string; shiftKey: boolean; nativeEvent?: { isComposing?: boolean } }): boolean {
201
+ if (event.key !== "Enter" || event.shiftKey) {
202
+ return false;
203
+ }
204
+ return event.nativeEvent?.isComposing !== true;
205
+ }
206
+
207
+ function generateClientEventId(): string {
208
+ const cryptoApi = globalThis.crypto;
209
+ if (cryptoApi && "randomUUID" in cryptoApi) {
210
+ return cryptoApi.randomUUID();
211
+ }
212
+ return `ce-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
213
+ }
@@ -0,0 +1,118 @@
1
+ import type {
2
+ CreateWorkspaceEnvironmentRequest,
3
+ UpdateWorkspaceEnvironmentRequest,
4
+ WorkspaceEnvironment,
5
+ WorkspaceEnvironmentVariableMetadata,
6
+ } from "@opengeni/sdk";
7
+ import { useCallback } from "react";
8
+ import { useOpenGeni, type ClientOverride } from "../provider";
9
+ import { useMutationRunner, usePolledValue } from "./internal";
10
+
11
+ export type UseEnvironmentsOptions = ClientOverride & {
12
+ pollIntervalMs?: number | undefined;
13
+ enabled?: boolean | undefined;
14
+ };
15
+
16
+ export type UseEnvironmentsResult = {
17
+ environments: WorkspaceEnvironment[];
18
+ loading: boolean;
19
+ error: Error | null;
20
+ refresh: () => Promise<void>;
21
+ create: (request: CreateWorkspaceEnvironmentRequest) => Promise<WorkspaceEnvironment | null>;
22
+ update: (environmentId: string, request: UpdateWorkspaceEnvironmentRequest) => Promise<WorkspaceEnvironment | null>;
23
+ remove: (environmentId: string) => Promise<boolean>;
24
+ /** Set/rotate a variable. Values are write-only — reads expose metadata only. */
25
+ setVariable: (environmentId: string, name: string, value: string) => Promise<WorkspaceEnvironmentVariableMetadata | null>;
26
+ deleteVariable: (environmentId: string, name: string) => Promise<boolean>;
27
+ mutating: boolean;
28
+ mutationError: Error | null;
29
+ clearMutationError: () => void;
30
+ };
31
+
32
+ /**
33
+ * Workspace environments (named, encrypted variable sets attached to sessions
34
+ * and scheduled tasks). Variable values are write-only end to end: this hook
35
+ * never sees a value after it is sent.
36
+ */
37
+ export function useEnvironments(options: UseEnvironmentsOptions = {}): UseEnvironmentsResult {
38
+ const { client, workspaceId } = useOpenGeni(options);
39
+ const load = useCallback(async () => await client.listEnvironments(workspaceId), [client, workspaceId]);
40
+ const state = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled: options.enabled });
41
+ const mutation = useMutationRunner();
42
+
43
+ const create = useCallback(
44
+ async (request: CreateWorkspaceEnvironmentRequest): Promise<WorkspaceEnvironment | null> => {
45
+ const result = await mutation.run(() => client.createEnvironment(workspaceId, request));
46
+ if (result) {
47
+ await state.refresh();
48
+ }
49
+ return result;
50
+ },
51
+ [client, workspaceId, mutation.run, state.refresh],
52
+ );
53
+
54
+ const update = useCallback(
55
+ async (environmentId: string, request: UpdateWorkspaceEnvironmentRequest): Promise<WorkspaceEnvironment | null> => {
56
+ const result = await mutation.run(() => client.updateEnvironment(workspaceId, environmentId, request));
57
+ if (result) {
58
+ await state.refresh();
59
+ }
60
+ return result;
61
+ },
62
+ [client, workspaceId, mutation.run, state.refresh],
63
+ );
64
+
65
+ const remove = useCallback(
66
+ async (environmentId: string): Promise<boolean> => {
67
+ const result = await mutation.run(async () => {
68
+ await client.deleteEnvironment(workspaceId, environmentId);
69
+ return true;
70
+ });
71
+ if (result) {
72
+ await state.refresh();
73
+ }
74
+ return result === true;
75
+ },
76
+ [client, workspaceId, mutation.run, state.refresh],
77
+ );
78
+
79
+ const setVariable = useCallback(
80
+ async (environmentId: string, name: string, value: string): Promise<WorkspaceEnvironmentVariableMetadata | null> => {
81
+ const result = await mutation.run(() => client.setEnvironmentVariable(workspaceId, environmentId, name, value));
82
+ if (result) {
83
+ await state.refresh();
84
+ }
85
+ return result;
86
+ },
87
+ [client, workspaceId, mutation.run, state.refresh],
88
+ );
89
+
90
+ const deleteVariable = useCallback(
91
+ async (environmentId: string, name: string): Promise<boolean> => {
92
+ const result = await mutation.run(async () => {
93
+ await client.deleteEnvironmentVariable(workspaceId, environmentId, name);
94
+ return true;
95
+ });
96
+ if (result) {
97
+ await state.refresh();
98
+ }
99
+ return result === true;
100
+ },
101
+ [client, workspaceId, mutation.run, state.refresh],
102
+ );
103
+
104
+ return {
105
+ environments: state.data ?? [],
106
+ loading: state.loading,
107
+ error: state.error,
108
+ refresh: state.refresh,
109
+ create,
110
+ update,
111
+ remove,
112
+ setVariable,
113
+ deleteVariable,
114
+ mutating: mutation.mutating,
115
+ mutationError: mutation.mutationError,
116
+ clearMutationError: mutation.clearMutationError,
117
+ };
118
+ }