@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,135 @@
|
|
|
1
|
+
import type { FileAsset, FileResourceRef } from "@opengeni/sdk";
|
|
2
|
+
import { useCallback, useState } from "react";
|
|
3
|
+
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
4
|
+
|
|
5
|
+
export type UseFileAttachmentsOptions = ClientOverride & {
|
|
6
|
+
/**
|
|
7
|
+
* Only files matching this predicate are accepted by {@link
|
|
8
|
+
* UseFileAttachmentsResult.addFromPaste} (the clipboard path). Defaults to
|
|
9
|
+
* `image/*` — the console's historical paste filter. {@link
|
|
10
|
+
* UseFileAttachmentsResult.addFiles} (the explicit picker / drop path)
|
|
11
|
+
* bypasses it.
|
|
12
|
+
*/
|
|
13
|
+
pasteFilter?: ((file: File) => boolean) | undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type FileAttachment = {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
contentType: string;
|
|
20
|
+
sizeBytes: number;
|
|
21
|
+
status: "uploading" | "ready" | "failed";
|
|
22
|
+
/** The SDK `FileAsset` once the upload finishes. */
|
|
23
|
+
file?: FileAsset | undefined;
|
|
24
|
+
/** Object-URL for an inline preview; minted for `image/*` files only. */
|
|
25
|
+
previewUrl?: string | undefined;
|
|
26
|
+
error?: string | undefined;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type UseFileAttachmentsResult = {
|
|
30
|
+
attachments: FileAttachment[];
|
|
31
|
+
/**
|
|
32
|
+
* `FileResourceRef[]` for every attachment that finished uploading — feed
|
|
33
|
+
* straight into `useComposer`'s `sendExtras.resources`.
|
|
34
|
+
*/
|
|
35
|
+
readyResources: FileResourceRef[];
|
|
36
|
+
/** True while any attachment is still uploading (drives the send-gate). */
|
|
37
|
+
uploading: boolean;
|
|
38
|
+
/** Explicit picker / drop path — uploads every file, no filter. */
|
|
39
|
+
addFiles: (files: Iterable<File>) => void;
|
|
40
|
+
/** Clipboard path — applies `pasteFilter` (default `image/*`) then uploads. */
|
|
41
|
+
addFromPaste: (event: { clipboardData: DataTransfer | null }) => void;
|
|
42
|
+
/** Remove one attachment; revokes its object-URL. */
|
|
43
|
+
remove: (id: string) => void;
|
|
44
|
+
/** Remove all; revokes every object-URL. Call from `useComposer`'s `onSent`. */
|
|
45
|
+
clear: () => void;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const isImage = (file: File): boolean => file.type.startsWith("image/");
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Upload-and-track state for files attached to the next message. Owns the
|
|
52
|
+
* full client-side upload layer: a per-file `uploading | ready | failed`
|
|
53
|
+
* status machine driven by the SDK's `client.uploadFile`, object-URL image
|
|
54
|
+
* previews with create/revoke lifecycle, the `image/*` clipboard paste filter,
|
|
55
|
+
* and a `FileResourceRef[]` projection that drops straight into a message's
|
|
56
|
+
* `resources`. Workspace-scoped, so it resolves both client and workspace from
|
|
57
|
+
* the {@link OpenGeniProvider} (or a per-call `{ client, workspaceId }`).
|
|
58
|
+
*/
|
|
59
|
+
export function useFileAttachments(options: UseFileAttachmentsOptions = {}): UseFileAttachmentsResult {
|
|
60
|
+
const { client, workspaceId } = useOpenGeni(options);
|
|
61
|
+
const pasteFilter = options.pasteFilter ?? isImage;
|
|
62
|
+
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
|
|
63
|
+
|
|
64
|
+
const addFiles = useCallback((files: Iterable<File>) => {
|
|
65
|
+
for (const file of files) {
|
|
66
|
+
const id = crypto.randomUUID();
|
|
67
|
+
const previewUrl = isImage(file) ? URL.createObjectURL(file) : undefined;
|
|
68
|
+
setAttachments((current) => [...current, {
|
|
69
|
+
id,
|
|
70
|
+
name: file.name || "image",
|
|
71
|
+
contentType: file.type || "application/octet-stream",
|
|
72
|
+
sizeBytes: file.size,
|
|
73
|
+
status: "uploading",
|
|
74
|
+
...(previewUrl ? { previewUrl } : {}),
|
|
75
|
+
}]);
|
|
76
|
+
void client.uploadFile(workspaceId, {
|
|
77
|
+
filename: file.name || "file",
|
|
78
|
+
contentType: file.type || "application/octet-stream",
|
|
79
|
+
data: file,
|
|
80
|
+
}).then((asset) => {
|
|
81
|
+
setAttachments((current) => current.map((attachment) => attachment.id === id
|
|
82
|
+
? { ...attachment, status: "ready", file: asset, name: asset.filename, contentType: asset.contentType, sizeBytes: asset.sizeBytes }
|
|
83
|
+
: attachment));
|
|
84
|
+
}).catch((error: unknown) => {
|
|
85
|
+
setAttachments((current) => current.map((attachment) => attachment.id === id
|
|
86
|
+
? { ...attachment, status: "failed", error: error instanceof Error ? error.message : String(error) }
|
|
87
|
+
: attachment));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}, [client, workspaceId]);
|
|
91
|
+
|
|
92
|
+
const addFromPaste = useCallback((event: { clipboardData: DataTransfer | null }) => {
|
|
93
|
+
const clipboardFiles = event.clipboardData?.files;
|
|
94
|
+
if (!clipboardFiles) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const files = [...clipboardFiles].filter(pasteFilter);
|
|
98
|
+
if (files.length > 0) {
|
|
99
|
+
addFiles(files);
|
|
100
|
+
}
|
|
101
|
+
}, [addFiles, pasteFilter]);
|
|
102
|
+
|
|
103
|
+
const remove = useCallback((id: string) => {
|
|
104
|
+
setAttachments((current) => {
|
|
105
|
+
const removed = current.find((attachment) => attachment.id === id);
|
|
106
|
+
if (removed?.previewUrl) {
|
|
107
|
+
URL.revokeObjectURL(removed.previewUrl);
|
|
108
|
+
}
|
|
109
|
+
return current.filter((attachment) => attachment.id !== id);
|
|
110
|
+
});
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
const clear = useCallback(() => {
|
|
114
|
+
setAttachments((current) => {
|
|
115
|
+
for (const attachment of current) {
|
|
116
|
+
if (attachment.previewUrl) {
|
|
117
|
+
URL.revokeObjectURL(attachment.previewUrl);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return [];
|
|
121
|
+
});
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
attachments,
|
|
126
|
+
readyResources: attachments.flatMap((attachment): FileResourceRef[] => attachment.status === "ready" && attachment.file
|
|
127
|
+
? [{ kind: "file", fileId: attachment.file.id }]
|
|
128
|
+
: []),
|
|
129
|
+
uploading: attachments.some((attachment) => attachment.status === "uploading"),
|
|
130
|
+
addFiles,
|
|
131
|
+
addFromPaste,
|
|
132
|
+
remove,
|
|
133
|
+
clear,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { OpenGeniApiError, type SessionEvent, type SessionGoal } 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 session goal (set/updated/completed/paused/...). */
|
|
7
|
+
export function isGoalEvent(event: Pick<SessionEvent, "type">): boolean {
|
|
8
|
+
return event.type.startsWith("goal.");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type UseGoalOptions = ClientOverride & SessionEventFeedOptions & {
|
|
12
|
+
/** Optional safety-net polling (ms). Off by default — goal.* events drive updates. */
|
|
13
|
+
pollIntervalMs?: number | undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type UseGoalResult = {
|
|
17
|
+
/** The session goal, or null when the session has none. */
|
|
18
|
+
goal: SessionGoal | null;
|
|
19
|
+
/** Convenience flags over `goal.status`. */
|
|
20
|
+
isActive: boolean;
|
|
21
|
+
isPaused: boolean;
|
|
22
|
+
isCompleted: boolean;
|
|
23
|
+
loading: boolean;
|
|
24
|
+
error: Error | null;
|
|
25
|
+
refresh: () => Promise<void>;
|
|
26
|
+
/** Pause the goal loop (PATCH status=paused). */
|
|
27
|
+
pause: (rationale?: string) => Promise<SessionGoal | null>;
|
|
28
|
+
/** Resume a paused goal: resets counters and re-arms continuations. */
|
|
29
|
+
resume: () => Promise<SessionGoal | null>;
|
|
30
|
+
/** True while a pause/resume is in flight. */
|
|
31
|
+
updating: boolean;
|
|
32
|
+
mutationError: Error | null;
|
|
33
|
+
clearMutationError: () => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The session's goal: state, the autonomy counters (`autoContinuations`,
|
|
38
|
+
* `noProgressStreak`), and pause/resume control. A goal-less session yields
|
|
39
|
+
* `goal: null` (the 404 is absorbed). Live-updates on `goal.*` events —
|
|
40
|
+
* pass `options.events` from `useSessionEvents` to reuse its stream.
|
|
41
|
+
*/
|
|
42
|
+
export function useGoal(sessionId: string | null | undefined, options: UseGoalOptions = {}): UseGoalResult {
|
|
43
|
+
const { client, workspaceId } = useOpenGeni(options);
|
|
44
|
+
const enabled = (options.enabled ?? true) && Boolean(sessionId);
|
|
45
|
+
const [goal, setGoal] = useState<SessionGoal | null>(null);
|
|
46
|
+
const [loading, setLoading] = useState(enabled);
|
|
47
|
+
const [error, setError] = useState<Error | null>(null);
|
|
48
|
+
const mutation = useMutationRunner();
|
|
49
|
+
const generation = useRef(0);
|
|
50
|
+
const targetKeyRef = useRef<string | null>(null);
|
|
51
|
+
|
|
52
|
+
const load = useCallback(async (): Promise<void> => {
|
|
53
|
+
if (!sessionId) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const ticket = ++generation.current;
|
|
57
|
+
try {
|
|
58
|
+
const fetched = await client.getGoal(workspaceId, sessionId);
|
|
59
|
+
if (ticket === generation.current) {
|
|
60
|
+
setGoal(fetched);
|
|
61
|
+
setError(null);
|
|
62
|
+
setLoading(false);
|
|
63
|
+
}
|
|
64
|
+
} catch (cause) {
|
|
65
|
+
if (ticket !== generation.current) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (cause instanceof OpenGeniApiError && cause.status === 404) {
|
|
69
|
+
// No goal is a normal state, not an error.
|
|
70
|
+
setGoal(null);
|
|
71
|
+
setError(null);
|
|
72
|
+
} else {
|
|
73
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
74
|
+
}
|
|
75
|
+
setLoading(false);
|
|
76
|
+
}
|
|
77
|
+
}, [client, workspaceId, sessionId]);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const targetKey = `${workspaceId} ${sessionId ?? ""}`;
|
|
81
|
+
if (targetKeyRef.current !== targetKey) {
|
|
82
|
+
targetKeyRef.current = targetKey;
|
|
83
|
+
setGoal(null);
|
|
84
|
+
setError(null);
|
|
85
|
+
}
|
|
86
|
+
if (!enabled) {
|
|
87
|
+
setLoading(false);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
setLoading(true);
|
|
91
|
+
void load();
|
|
92
|
+
const pollIntervalMs = options.pollIntervalMs;
|
|
93
|
+
if (pollIntervalMs === undefined || pollIntervalMs <= 0) {
|
|
94
|
+
return () => {
|
|
95
|
+
generation.current += 1;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const timer = setInterval(() => void load(), pollIntervalMs);
|
|
99
|
+
return () => {
|
|
100
|
+
clearInterval(timer);
|
|
101
|
+
generation.current += 1;
|
|
102
|
+
};
|
|
103
|
+
}, [load, enabled, workspaceId, sessionId, options.pollIntervalMs]);
|
|
104
|
+
|
|
105
|
+
const scheduleRefresh = useDebouncedCallback(() => void load());
|
|
106
|
+
useSessionEventTrigger(client, workspaceId, sessionId, isGoalEvent, scheduleRefresh, {
|
|
107
|
+
enabled,
|
|
108
|
+
...(options.events !== undefined ? { events: options.events } : {}),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const pause = useCallback(
|
|
112
|
+
async (rationale?: string): Promise<SessionGoal | null> => {
|
|
113
|
+
if (!sessionId) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const result = await mutation.run(() =>
|
|
117
|
+
client.updateGoal(workspaceId, sessionId, {
|
|
118
|
+
status: "paused",
|
|
119
|
+
...(rationale !== undefined ? { rationale } : {}),
|
|
120
|
+
}));
|
|
121
|
+
if (result) {
|
|
122
|
+
setGoal(result);
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
},
|
|
126
|
+
[client, workspaceId, sessionId, mutation.run],
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const resume = useCallback(async (): Promise<SessionGoal | null> => {
|
|
130
|
+
if (!sessionId) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const result = await mutation.run(() => client.updateGoal(workspaceId, sessionId, { status: "active" }));
|
|
134
|
+
if (result) {
|
|
135
|
+
setGoal(result);
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
}, [client, workspaceId, sessionId, mutation.run]);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
goal,
|
|
142
|
+
isActive: goal?.status === "active",
|
|
143
|
+
isPaused: goal?.status === "paused",
|
|
144
|
+
isCompleted: goal?.status === "completed",
|
|
145
|
+
loading,
|
|
146
|
+
error,
|
|
147
|
+
refresh: load,
|
|
148
|
+
pause,
|
|
149
|
+
resume,
|
|
150
|
+
updating: mutation.mutating,
|
|
151
|
+
mutationError: mutation.mutationError,
|
|
152
|
+
clearMutationError: mutation.clearMutationError,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CapabilityPack,
|
|
3
|
+
EnablePackRequest,
|
|
4
|
+
PackInstallation,
|
|
5
|
+
RegisterCapabilityPackRequest,
|
|
6
|
+
WorkspaceRegisteredPack,
|
|
7
|
+
} from "@opengeni/sdk";
|
|
8
|
+
import { useCallback } from "react";
|
|
9
|
+
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
10
|
+
import { useMutationRunner, usePolledValue } from "./internal";
|
|
11
|
+
|
|
12
|
+
export type UsePacksOptions = ClientOverride & {
|
|
13
|
+
pollIntervalMs?: number | undefined;
|
|
14
|
+
enabled?: boolean | undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type UsePacksResult = {
|
|
18
|
+
/** Built-in + registered packs available to the workspace. */
|
|
19
|
+
packs: CapabilityPack[];
|
|
20
|
+
/** Enable/disable state per pack. */
|
|
21
|
+
installations: PackInstallation[];
|
|
22
|
+
/** The installation for a pack id, if any. */
|
|
23
|
+
installationFor: (packId: string) => PackInstallation | null;
|
|
24
|
+
loading: boolean;
|
|
25
|
+
error: Error | null;
|
|
26
|
+
refresh: () => Promise<void>;
|
|
27
|
+
/** Register (or replace) a workspace-scoped pack manifest. */
|
|
28
|
+
register: (manifest: RegisterCapabilityPackRequest) => Promise<WorkspaceRegisteredPack | null>;
|
|
29
|
+
enable: (packId: string, request?: EnablePackRequest) => Promise<PackInstallation | null>;
|
|
30
|
+
/** Unregister a workspace-scoped pack (built-ins cannot be removed). */
|
|
31
|
+
remove: (packId: string) => Promise<boolean>;
|
|
32
|
+
mutating: boolean;
|
|
33
|
+
mutationError: Error | null;
|
|
34
|
+
clearMutationError: () => void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Capability packs: catalog + installations + register/enable/unregister. */
|
|
38
|
+
export function usePacks(options: UsePacksOptions = {}): UsePacksResult {
|
|
39
|
+
const { client, workspaceId } = useOpenGeni(options);
|
|
40
|
+
const load = useCallback(async () => await client.listPacks(workspaceId), [client, workspaceId]);
|
|
41
|
+
const state = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled: options.enabled });
|
|
42
|
+
const mutation = useMutationRunner();
|
|
43
|
+
|
|
44
|
+
const register = useCallback(
|
|
45
|
+
async (manifest: RegisterCapabilityPackRequest): Promise<WorkspaceRegisteredPack | null> => {
|
|
46
|
+
const result = await mutation.run(() => client.registerPack(workspaceId, manifest));
|
|
47
|
+
if (result) {
|
|
48
|
+
await state.refresh();
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
},
|
|
52
|
+
[client, workspaceId, mutation.run, state.refresh],
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const enable = useCallback(
|
|
56
|
+
async (packId: string, request: EnablePackRequest = {}): Promise<PackInstallation | null> => {
|
|
57
|
+
const result = await mutation.run(() => client.enablePack(workspaceId, packId, request));
|
|
58
|
+
if (result) {
|
|
59
|
+
await state.refresh();
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
},
|
|
63
|
+
[client, workspaceId, mutation.run, state.refresh],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const remove = useCallback(
|
|
67
|
+
async (packId: string): Promise<boolean> => {
|
|
68
|
+
const result = await mutation.run(async () => {
|
|
69
|
+
await client.deletePack(workspaceId, packId);
|
|
70
|
+
return true;
|
|
71
|
+
});
|
|
72
|
+
if (result) {
|
|
73
|
+
await state.refresh();
|
|
74
|
+
}
|
|
75
|
+
return result === true;
|
|
76
|
+
},
|
|
77
|
+
[client, workspaceId, mutation.run, state.refresh],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const installations = state.data?.installations ?? [];
|
|
81
|
+
const installationFor = useCallback(
|
|
82
|
+
(packId: string): PackInstallation | null =>
|
|
83
|
+
installations.find((installation) => installation.packId === packId) ?? null,
|
|
84
|
+
[installations],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
packs: state.data?.packs ?? [],
|
|
89
|
+
installations,
|
|
90
|
+
installationFor,
|
|
91
|
+
loading: state.loading,
|
|
92
|
+
error: state.error,
|
|
93
|
+
refresh: state.refresh,
|
|
94
|
+
register,
|
|
95
|
+
enable,
|
|
96
|
+
remove,
|
|
97
|
+
mutating: mutation.mutating,
|
|
98
|
+
mutationError: mutation.mutationError,
|
|
99
|
+
clearMutationError: mutation.clearMutationError,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ScheduledTask } from "@opengeni/sdk";
|
|
2
|
+
import { useCallback } from "react";
|
|
3
|
+
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
4
|
+
import { usePolledValue } from "./internal";
|
|
5
|
+
|
|
6
|
+
export type UseScheduledTasksOptions = ClientOverride & {
|
|
7
|
+
limit?: number | undefined;
|
|
8
|
+
pollIntervalMs?: number | undefined;
|
|
9
|
+
enabled?: boolean | undefined;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type UseScheduledTasksResult = {
|
|
13
|
+
tasks: ScheduledTask[];
|
|
14
|
+
loading: boolean;
|
|
15
|
+
error: Error | null;
|
|
16
|
+
refresh: () => Promise<void>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** List the workspace's scheduled tasks (drift checks, sentinels, reapers, ...). */
|
|
20
|
+
export function useScheduledTasks(options: UseScheduledTasksOptions = {}): UseScheduledTasksResult {
|
|
21
|
+
const { client, workspaceId } = useOpenGeni(options);
|
|
22
|
+
const limit = options.limit;
|
|
23
|
+
const load = useCallback(
|
|
24
|
+
async () => await client.listScheduledTasks(workspaceId, limit !== undefined ? { limit } : {}),
|
|
25
|
+
[client, workspaceId, limit],
|
|
26
|
+
);
|
|
27
|
+
const state = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled: options.enabled });
|
|
28
|
+
return { tasks: state.data ?? [], loading: state.loading, error: state.error, refresh: state.refresh };
|
|
29
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { SessionEvent } from "@opengeni/sdk";
|
|
2
|
+
import { useCallback } from "react";
|
|
3
|
+
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
4
|
+
import { useMutationRunner } from "./internal";
|
|
5
|
+
|
|
6
|
+
export type UseSessionControlOptions = ClientOverride;
|
|
7
|
+
|
|
8
|
+
export type UseSessionControlResult = {
|
|
9
|
+
/** Interrupt the running turn (the explicit alternative to queueing). */
|
|
10
|
+
interrupt: (reason?: string) => Promise<SessionEvent | null>;
|
|
11
|
+
interrupting: boolean;
|
|
12
|
+
/** Approve a pending `requires_action` approval. */
|
|
13
|
+
approve: (approvalId: string, message?: string) => Promise<SessionEvent | null>;
|
|
14
|
+
/** Reject a pending `requires_action` approval. */
|
|
15
|
+
reject: (approvalId: string, message?: string) => Promise<SessionEvent | null>;
|
|
16
|
+
/** True while an approval decision is in flight. */
|
|
17
|
+
responding: boolean;
|
|
18
|
+
error: Error | null;
|
|
19
|
+
clearError: () => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Session control events: interrupt and approval decisions. Pair with
|
|
24
|
+
* `useSessionEvents` (for `session.requiresAction` payloads carrying the
|
|
25
|
+
* `approvalId`) to render an approval bar.
|
|
26
|
+
*/
|
|
27
|
+
export function useSessionControl(
|
|
28
|
+
sessionId: string | null | undefined,
|
|
29
|
+
options: UseSessionControlOptions = {},
|
|
30
|
+
): UseSessionControlResult {
|
|
31
|
+
const { client, workspaceId } = useOpenGeni(options);
|
|
32
|
+
const interruptMutation = useMutationRunner();
|
|
33
|
+
const approvalMutation = useMutationRunner();
|
|
34
|
+
|
|
35
|
+
const interrupt = useCallback(
|
|
36
|
+
async (reason?: string): Promise<SessionEvent | null> => {
|
|
37
|
+
if (!sessionId) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return await interruptMutation.run(() =>
|
|
41
|
+
client.interrupt(workspaceId, sessionId, reason !== undefined ? { reason } : {}));
|
|
42
|
+
},
|
|
43
|
+
[client, workspaceId, sessionId, interruptMutation.run],
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const decide = useCallback(
|
|
47
|
+
async (approvalId: string, decision: "approve" | "reject", message?: string): Promise<SessionEvent | null> => {
|
|
48
|
+
if (!sessionId) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return await approvalMutation.run(() =>
|
|
52
|
+
client.sendApprovalDecision(workspaceId, sessionId, {
|
|
53
|
+
approvalId,
|
|
54
|
+
decision,
|
|
55
|
+
...(message !== undefined ? { message } : {}),
|
|
56
|
+
}));
|
|
57
|
+
},
|
|
58
|
+
[client, workspaceId, sessionId, approvalMutation.run],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const approve = useCallback(
|
|
62
|
+
async (approvalId: string, message?: string) => await decide(approvalId, "approve", message),
|
|
63
|
+
[decide],
|
|
64
|
+
);
|
|
65
|
+
const reject = useCallback(
|
|
66
|
+
async (approvalId: string, message?: string) => await decide(approvalId, "reject", message),
|
|
67
|
+
[decide],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const error = approvalMutation.mutationError ?? interruptMutation.mutationError;
|
|
71
|
+
const clearError = useCallback(() => {
|
|
72
|
+
interruptMutation.clearMutationError();
|
|
73
|
+
approvalMutation.clearMutationError();
|
|
74
|
+
}, [interruptMutation.clearMutationError, approvalMutation.clearMutationError]);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
interrupt,
|
|
78
|
+
interrupting: interruptMutation.mutating,
|
|
79
|
+
approve,
|
|
80
|
+
reject,
|
|
81
|
+
responding: approvalMutation.mutating,
|
|
82
|
+
error,
|
|
83
|
+
clearError,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { SessionEvent, SessionStatus, StreamConnectionState } from "@opengeni/sdk";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
4
|
+
import { buildTimeline, sessionStatusFromEvents, type TimelineItem } from "../timeline";
|
|
5
|
+
|
|
6
|
+
export type SessionEventsConnectionState = StreamConnectionState | "idle" | "ended" | "error";
|
|
7
|
+
|
|
8
|
+
export type UseSessionEventsOptions = ClientOverride & {
|
|
9
|
+
/** Resume after this sequence (exclusive). Defaults to 0 = full replay. */
|
|
10
|
+
after?: number | undefined;
|
|
11
|
+
/** Pause the stream without unmounting (e.g. hidden tab). Defaults to true. */
|
|
12
|
+
enabled?: boolean | undefined;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type UseSessionEventsResult = {
|
|
16
|
+
/** Replayed + live events, ordered by sequence, no gaps, no duplicates. */
|
|
17
|
+
events: SessionEvent[];
|
|
18
|
+
/** Projected, renderable timeline (memoized over `events`). */
|
|
19
|
+
timeline: TimelineItem[];
|
|
20
|
+
/** Latest session status observed in the event log, if any. */
|
|
21
|
+
sessionStatus: SessionStatus | null;
|
|
22
|
+
connectionState: SessionEventsConnectionState;
|
|
23
|
+
/** Highest sequence seen so far (0 before the first event). */
|
|
24
|
+
lastSequence: number;
|
|
25
|
+
error: Error | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Live-stream a session's event log with replay-by-sequence, reconnect, and
|
|
30
|
+
* batched React updates. The SDK guarantees ordered, gap-free, exactly-once
|
|
31
|
+
* delivery; this hook accumulates the log and projects it into a timeline.
|
|
32
|
+
*/
|
|
33
|
+
export function useSessionEvents(sessionId: string | null | undefined, options: UseSessionEventsOptions = {}): UseSessionEventsResult {
|
|
34
|
+
const { client, workspaceId } = useOpenGeni(options);
|
|
35
|
+
const enabled = options.enabled ?? true;
|
|
36
|
+
const after = options.after ?? 0;
|
|
37
|
+
|
|
38
|
+
const [events, setEvents] = useState<SessionEvent[]>([]);
|
|
39
|
+
const [connectionState, setConnectionState] = useState<SessionEventsConnectionState>("idle");
|
|
40
|
+
const [error, setError] = useState<Error | null>(null);
|
|
41
|
+
const lastSequenceRef = useRef(after);
|
|
42
|
+
const streamKeyRef = useRef<string | null>(null);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
// Reset the accumulated log only when the stream identity changes —
|
|
46
|
+
// pausing via `enabled: false` keeps the timeline visible.
|
|
47
|
+
const streamKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${after}`;
|
|
48
|
+
if (streamKeyRef.current !== streamKey) {
|
|
49
|
+
streamKeyRef.current = streamKey;
|
|
50
|
+
setEvents([]);
|
|
51
|
+
setError(null);
|
|
52
|
+
lastSequenceRef.current = after;
|
|
53
|
+
}
|
|
54
|
+
if (!sessionId || !enabled) {
|
|
55
|
+
setConnectionState("idle");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
// Batch yielded events into one React update per flush window so a long
|
|
60
|
+
// replay (thousands of events) does not render per event.
|
|
61
|
+
let pending: SessionEvent[] = [];
|
|
62
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
63
|
+
const flush = () => {
|
|
64
|
+
flushTimer = null;
|
|
65
|
+
if (pending.length === 0) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const batch = pending;
|
|
69
|
+
pending = [];
|
|
70
|
+
// The resume cursor only advances with delivered batches: events still
|
|
71
|
+
// sitting in `pending` when the stream is torn down are re-fetched on
|
|
72
|
+
// the next connect instead of being skipped.
|
|
73
|
+
const lastInBatch = batch[batch.length - 1];
|
|
74
|
+
if (lastInBatch) {
|
|
75
|
+
lastSequenceRef.current = lastInBatch.sequence;
|
|
76
|
+
}
|
|
77
|
+
setEvents((existing) => [...existing, ...batch]);
|
|
78
|
+
};
|
|
79
|
+
const scheduleFlush = () => {
|
|
80
|
+
flushTimer ??= setTimeout(flush, 16);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
void (async () => {
|
|
84
|
+
try {
|
|
85
|
+
const stream = client.streamEvents(workspaceId, sessionId, {
|
|
86
|
+
after: lastSequenceRef.current,
|
|
87
|
+
signal: controller.signal,
|
|
88
|
+
onStateChange: (state) => {
|
|
89
|
+
if (!controller.signal.aborted) {
|
|
90
|
+
setConnectionState(state);
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
for await (const event of stream) {
|
|
95
|
+
pending.push(event);
|
|
96
|
+
scheduleFlush();
|
|
97
|
+
}
|
|
98
|
+
if (!controller.signal.aborted) {
|
|
99
|
+
flush();
|
|
100
|
+
setConnectionState("ended");
|
|
101
|
+
}
|
|
102
|
+
} catch (cause) {
|
|
103
|
+
if (!controller.signal.aborted) {
|
|
104
|
+
flush();
|
|
105
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
106
|
+
setConnectionState("error");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
})();
|
|
110
|
+
|
|
111
|
+
return () => {
|
|
112
|
+
controller.abort();
|
|
113
|
+
if (flushTimer !== null) {
|
|
114
|
+
clearTimeout(flushTimer);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}, [client, workspaceId, sessionId, after, enabled]);
|
|
118
|
+
|
|
119
|
+
const timeline = useMemo(() => buildTimeline(events), [events]);
|
|
120
|
+
const sessionStatus = useMemo(() => sessionStatusFromEvents(events), [events]);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
events,
|
|
124
|
+
timeline,
|
|
125
|
+
sessionStatus,
|
|
126
|
+
connectionState,
|
|
127
|
+
lastSequence: lastSequenceRef.current,
|
|
128
|
+
error,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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 UseSessionOptions = ClientOverride & {
|
|
7
|
+
/** Re-fetch on an interval (ms). Off by default — pair with `useSessionEvents` for live status. */
|
|
8
|
+
pollIntervalMs?: number | undefined;
|
|
9
|
+
enabled?: boolean | undefined;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type UseSessionResult = {
|
|
13
|
+
session: Session | null;
|
|
14
|
+
loading: boolean;
|
|
15
|
+
error: Error | null;
|
|
16
|
+
refresh: () => Promise<void>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Fetch one session (with optional polling). */
|
|
20
|
+
export function useSession(sessionId: string | null | undefined, options: UseSessionOptions = {}): UseSessionResult {
|
|
21
|
+
const { client, workspaceId } = useOpenGeni(options);
|
|
22
|
+
const load = useCallback(async () => {
|
|
23
|
+
if (!sessionId) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return await client.getSession(workspaceId, sessionId);
|
|
27
|
+
}, [client, workspaceId, sessionId]);
|
|
28
|
+
const state = usePolledValue(load, {
|
|
29
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
30
|
+
enabled: (options.enabled ?? true) && Boolean(sessionId),
|
|
31
|
+
});
|
|
32
|
+
return { session: state.data ?? null, loading: state.loading, error: state.error, refresh: state.refresh };
|
|
33
|
+
}
|