@opengeni/react 0.3.0 → 0.4.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.
Files changed (40) hide show
  1. package/dist/index.d.ts +1035 -14
  2. package/dist/index.js +6867 -1884
  3. package/dist/index.js.map +1 -1
  4. package/package.json +65 -2
  5. package/src/client.ts +21 -0
  6. package/src/components/code-editor.tsx +398 -0
  7. package/src/components/desktop-viewer.tsx +647 -0
  8. package/src/components/diff-view.tsx +230 -0
  9. package/src/components/file-browser.tsx +838 -0
  10. package/src/components/message-timeline.tsx +70 -196
  11. package/src/components/pierre-diff.tsx +140 -0
  12. package/src/components/pierre-file.tsx +142 -0
  13. package/src/components/sandbox-files.tsx +509 -0
  14. package/src/components/sandbox-terminal.tsx +425 -0
  15. package/src/components/workspace-dock.tsx +247 -0
  16. package/src/hooks/use-desktop-stream.ts +214 -0
  17. package/src/hooks/use-sandbox-files.ts +670 -0
  18. package/src/hooks/use-sandbox-git.ts +105 -0
  19. package/src/hooks/use-sandbox-terminal.ts +226 -0
  20. package/src/hooks/use-session-capabilities.ts +415 -0
  21. package/src/hooks/use-terminal-stream.ts +207 -0
  22. package/src/index.ts +111 -2
  23. package/src/lib/cn.ts +20 -1
  24. package/src/lib/git-patch.ts +37 -0
  25. package/src/lib/use-theme-type.ts +40 -0
  26. package/src/lib/xterm-theme.ts +34 -0
  27. package/src/timeline/activity-rail.tsx +207 -0
  28. package/src/timeline/disclosure-context.tsx +34 -0
  29. package/src/timeline/index.ts +85 -0
  30. package/src/timeline/parsers.ts +248 -0
  31. package/src/{timeline.ts → timeline/projection.ts} +59 -134
  32. package/src/timeline/registry.ts +96 -0
  33. package/src/timeline/screenshot-lightbox.tsx +152 -0
  34. package/src/timeline/shared.tsx +481 -0
  35. package/src/timeline/tool-diff.tsx +91 -0
  36. package/src/timeline/tool-renderers.tsx +882 -0
  37. package/src/timeline/turn-summary.tsx +125 -0
  38. package/src/timeline/types.ts +131 -0
  39. package/src/types/external.d.ts +7 -0
  40. package/styles/index.css +72 -0
@@ -0,0 +1,105 @@
1
+ import type { GitFileDiff, SessionEvent } from "@opengeni/sdk";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useOpenGeni, type ClientOverride } from "../provider";
4
+
5
+ export type UseSandboxGitOptions = ClientOverride & {
6
+ /** Live event log (usually `useSessionEvents().events`) — drives auto-refresh
7
+ * on `git.changed`. */
8
+ events?: SessionEvent[] | undefined;
9
+ /** Repo root within the workspace (multi-repo). Default: workspace root. */
10
+ repoPath?: string | undefined;
11
+ /** Diff the staged index vs HEAD (`--cached`) instead of the working tree. */
12
+ staged?: boolean | undefined;
13
+ /** Hold off the initial fetch. Default true. */
14
+ enabled?: boolean | undefined;
15
+ };
16
+
17
+ export type UseSandboxGitResult = {
18
+ /** Working-tree (or staged) diff vs HEAD — the structured hunks the Pierre
19
+ * diff view renders. */
20
+ diff: GitFileDiff[];
21
+ branch: string | null;
22
+ /** Whether a repo is actually mounted (drives "no repository" vs "no changes"). */
23
+ isRepo: boolean;
24
+ ahead: number;
25
+ behind: number;
26
+ refresh: () => Promise<void>;
27
+ loading: boolean;
28
+ error: Error | null;
29
+ };
30
+
31
+ /**
32
+ * Project the Git service into the Pierre diff data contract: structured
33
+ * `GitFileDiff[]` (per-file hunks with per-line old/new numbers, rename
34
+ * detection, binary flag, add/del counts) plus branch + ahead/behind. The
35
+ * `git diff` runs in-box (API-direct) and the hunks come back inline. Refreshes
36
+ * on `git.changed`.
37
+ */
38
+ export function useSandboxGit(
39
+ sessionId: string | null | undefined,
40
+ options: UseSandboxGitOptions = {},
41
+ ): UseSandboxGitResult {
42
+ const { client, workspaceId } = useOpenGeni(options);
43
+ const enabled = (options.enabled ?? true) && Boolean(sessionId);
44
+ const repoPath = options.repoPath ?? "";
45
+ const staged = options.staged ?? false;
46
+
47
+ const [diff, setDiff] = useState<GitFileDiff[]>([]);
48
+ const [branch, setBranch] = useState<string | null>(null);
49
+ const [isRepo, setIsRepo] = useState(false);
50
+ const [ahead, setAhead] = useState(0);
51
+ const [behind, setBehind] = useState(0);
52
+ const [loading, setLoading] = useState(false);
53
+ const [error, setError] = useState<Error | null>(null);
54
+
55
+ const refresh = useCallback(async () => {
56
+ if (!sessionId) return;
57
+ setLoading(true);
58
+ setError(null);
59
+ try {
60
+ const status = await client.gitStatus(workspaceId, sessionId, { path: repoPath });
61
+ setIsRepo(status.isRepo);
62
+ setBranch(status.head);
63
+ setAhead(status.ahead);
64
+ setBehind(status.behind);
65
+ if (!status.isRepo) {
66
+ setDiff([]);
67
+ return;
68
+ }
69
+ const result = await client.gitDiff(workspaceId, sessionId, { path: repoPath, staged });
70
+ setDiff(result.files);
71
+ } catch (cause) {
72
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
73
+ } finally {
74
+ setLoading(false);
75
+ }
76
+ }, [client, workspaceId, sessionId, repoPath, staged]);
77
+
78
+ useEffect(() => {
79
+ if (!enabled) {
80
+ setDiff([]);
81
+ setIsRepo(false);
82
+ setBranch(null);
83
+ return;
84
+ }
85
+ void refresh();
86
+ }, [enabled, refresh]);
87
+
88
+ const events = options.events;
89
+ const lastChangeRef = useRef(0);
90
+ useEffect(() => {
91
+ if (!enabled || !events) return;
92
+ let latest = lastChangeRef.current;
93
+ for (const event of events) {
94
+ if (event.type === "git.changed" && event.sequence > latest) {
95
+ latest = event.sequence;
96
+ }
97
+ }
98
+ if (latest > lastChangeRef.current) {
99
+ lastChangeRef.current = latest;
100
+ void refresh();
101
+ }
102
+ }, [enabled, events, refresh]);
103
+
104
+ return { diff, branch, isRepo, ahead, behind, refresh, loading, error };
105
+ }
@@ -0,0 +1,226 @@
1
+ import type {
2
+ SandboxCommandOutputDeltaPayload,
3
+ SessionEvent,
4
+ TerminalPtyExitedPayload,
5
+ TerminalPtyOutputDeltaPayload,
6
+ } from "@opengeni/sdk";
7
+ import { OpenGeniApiError } from "@opengeni/sdk";
8
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9
+ import { useOpenGeni, type ClientOverride } from "../provider";
10
+
11
+ export type TerminalChunk = {
12
+ /** Stable key (the source event id) so the xterm writer tracks a written-cursor. */
13
+ id: string;
14
+ /** Raw output bytes (utf-8 lossy) — written verbatim into xterm. */
15
+ text: string;
16
+ /** stdout vs stderr (drives optional tinting). */
17
+ stream: "stdout" | "stderr";
18
+ /** Global ordering: the source event sequence. */
19
+ seq: number;
20
+ };
21
+
22
+ export type UseSandboxTerminalOptions = ClientOverride & {
23
+ /** The live session event log (usually `useSessionEvents().events`). */
24
+ events: SessionEvent[];
25
+ /** Restrict to one PTY (by ptyId). Omit to interleave the agent firehose +
26
+ * every PTY. */
27
+ ptyId?: string | undefined;
28
+ /** Include the agent's command-output firehose (sandbox.command.output.delta).
29
+ * Default true — the read-only "terminal-as-events" the data path settled on. */
30
+ includeAgentFirehose?: boolean | undefined;
31
+ /**
32
+ * OPEN an interactive PTY against the box so the user can type, not just watch.
33
+ * When true (and the session is live) the hook calls `terminalPtyOpen` once,
34
+ * tracks the returned ptyId, exposes `write` immediately (bound to that ptyId),
35
+ * and closes the PTY on unmount. The PTY's banner + every output delta ride the
36
+ * SSE spine (`terminal.pty.*`) back into `events`, so xterm fills in. Default
37
+ * false — a caller that only wants the read-only firehose stays projection-only.
38
+ */
39
+ interactive?: boolean | undefined;
40
+ /** Lease liveness ("cold" | "warm" | "draining"). The interactive PTY is only
41
+ * opened once the box is warm — opening on a cold box (ptyCapable is advertised
42
+ * cold too) races the box and leaves a dead read-only terminal. */
43
+ liveness?: string | undefined;
44
+ };
45
+
46
+ export type UseSandboxTerminalResult = {
47
+ /** Ordered, deduped output chunks to write() into xterm.js. */
48
+ chunks: TerminalChunk[];
49
+ /** Whether a PTY is currently open (drives the prompt/cursor affordance). */
50
+ running: boolean;
51
+ /**
52
+ * Interactive write fn when a PTY is open and the backend supports stdin
53
+ * (`terminal.transport === "pty-ws"` / `PtyOpenResponse.supportsInput`). Null
54
+ * in the read-only event-projection case (v1 default).
55
+ */
56
+ write: ((data: string) => void) | null;
57
+ /** The active PTY id, if one is open. */
58
+ activePtyId: string | null;
59
+ /** Close the active PTY (no-op when none is open). */
60
+ close: () => void;
61
+ /** A PTY-open failure (interactive mode), if any. */
62
+ error: Error | null;
63
+ };
64
+
65
+ /**
66
+ * Project the Channel-A event log into an xterm-writable byte stream. The
67
+ * terminal is "terminal-as-events": there is NO new socket in v1 — the agent's
68
+ * command output (`sandbox.command.output.delta`) and any interactive PTY
69
+ * (`terminal.pty.output.delta`) ride the existing SSE spine. When a PTY is open
70
+ * and the backend accepts stdin, `write` pipes keystrokes via the SDK
71
+ * `terminalPtyWrite` (the synchronous Channel-A control path).
72
+ */
73
+ export function useSandboxTerminal(
74
+ sessionId: string | null | undefined,
75
+ options: UseSandboxTerminalOptions,
76
+ ): UseSandboxTerminalResult {
77
+ const { client, workspaceId } = useOpenGeni(options);
78
+ const includeAgentFirehose = options.includeAgentFirehose ?? true;
79
+ const interactive = options.interactive ?? false;
80
+ // When the caller controls a fixed ptyId we project that one; otherwise (the
81
+ // interactive default) the hook opens its OWN PTY and tracks it here so `write`
82
+ // is live before the `terminal.pty.started` event round-trips through SSE.
83
+ const ptyFilter = options.ptyId;
84
+ const liveness = options.liveness;
85
+ const [openedPtyId, setOpenedPtyId] = useState<string | null>(null);
86
+ const [openError, setOpenError] = useState<Error | null>(null);
87
+ // Bumped to force a fresh PTY open after a write reveals the old PTY was lost
88
+ // (a 409 "pty session lost" — the box rolled over since the open). This makes
89
+ // the interactive terminal self-heal instead of dead-ending on a stale session.
90
+ const [reopenNonce, setReopenNonce] = useState(0);
91
+
92
+ // Open ONE interactive PTY against the box, close it on unmount/identity
93
+ // change. The open's banner + subsequent output deltas ride A1, so xterm fills
94
+ // from `events` — we don't thread the open's body output here (the SSE
95
+ // projection is the single source of truth for what's written). The open is
96
+ // gated on a WARM box: opening on a cold/warming lease races the box (the PTY
97
+ // exec-session is created on a box that the next op may not resume), which is
98
+ // exactly the "session not found" terminal failure; we wait for warm/draining.
99
+ const openInFlight = useRef(false);
100
+ const boxWarm = liveness === undefined || liveness === "warm" || liveness === "draining";
101
+ useEffect(() => {
102
+ if (!interactive || !sessionId || ptyFilter || !boxWarm) return;
103
+ let cancelled = false;
104
+ openInFlight.current = true;
105
+ let openedId: string | null = null;
106
+ void client
107
+ .terminalPtyOpen(workspaceId, sessionId, {})
108
+ .then((res) => {
109
+ if (cancelled) {
110
+ // Raced past unmount — close the orphan we just opened.
111
+ void client.terminalPtyClose(workspaceId, sessionId, { ptyId: res.ptyId }).catch(() => {});
112
+ return;
113
+ }
114
+ openedId = res.ptyId;
115
+ setOpenedPtyId(res.ptyId);
116
+ })
117
+ .catch((cause) => {
118
+ if (!cancelled) setOpenError(cause instanceof Error ? cause : new Error(String(cause)));
119
+ })
120
+ .finally(() => {
121
+ openInFlight.current = false;
122
+ });
123
+ return () => {
124
+ cancelled = true;
125
+ setOpenedPtyId(null);
126
+ const id = openedId;
127
+ if (id) void client.terminalPtyClose(workspaceId, sessionId, { ptyId: id }).catch(() => {});
128
+ };
129
+ }, [interactive, client, workspaceId, sessionId, ptyFilter, boxWarm, reopenNonce]);
130
+
131
+ const { chunks, openPty, supportsInput } = useMemo(() => {
132
+ const out: TerminalChunk[] = [];
133
+ // Track PTY lifecycle so `running`/`write` reflect the latest state.
134
+ const open = new Map<string, { supportsInput: boolean }>();
135
+ let lastOpened: string | null = null;
136
+ let lastSupportsInput = false;
137
+
138
+ for (const event of options.events) {
139
+ if (event.type === "terminal.pty.started") {
140
+ const payload = event.payload as { ptyId?: string } | null;
141
+ if (payload?.ptyId) {
142
+ open.set(payload.ptyId, { supportsInput: true });
143
+ lastOpened = payload.ptyId;
144
+ }
145
+ continue;
146
+ }
147
+ if (event.type === "terminal.pty.exited") {
148
+ const payload = event.payload as TerminalPtyExitedPayload | null;
149
+ if (payload?.ptyId) {
150
+ open.delete(payload.ptyId);
151
+ if (lastOpened === payload.ptyId) lastOpened = null;
152
+ }
153
+ continue;
154
+ }
155
+ if (event.type === "terminal.pty.output.delta") {
156
+ const payload = event.payload as TerminalPtyOutputDeltaPayload | null;
157
+ if (!payload || (ptyFilter && payload.ptyId !== ptyFilter)) continue;
158
+ out.push({
159
+ id: event.id,
160
+ text: payload.chunk,
161
+ stream: payload.stream === "stderr" ? "stderr" : "stdout",
162
+ seq: event.sequence,
163
+ });
164
+ continue;
165
+ }
166
+ if (includeAgentFirehose && !ptyFilter && event.type === "sandbox.command.output.delta") {
167
+ const payload = event.payload as SandboxCommandOutputDeltaPayload | null;
168
+ if (!payload?.chunk) continue;
169
+ out.push({
170
+ id: event.id,
171
+ text: payload.chunk,
172
+ stream: payload.stream === "stderr" ? "stderr" : "stdout",
173
+ seq: event.sequence,
174
+ });
175
+ }
176
+ }
177
+
178
+ // Stable order: by sequence (the SSE spine guarantees per-session ordering).
179
+ out.sort((a, b) => a.seq - b.seq);
180
+ const activePty = ptyFilter && open.has(ptyFilter) ? ptyFilter : lastOpened;
181
+ lastSupportsInput = activePty ? (open.get(activePty)?.supportsInput ?? false) : false;
182
+ return { chunks: out, openPty: activePty, supportsInput: lastSupportsInput };
183
+ }, [options.events, ptyFilter, includeAgentFirehose]);
184
+
185
+ // The PTY this hook actively drives: the one it opened (interactive) wins so
186
+ // `write` is live the instant the open resolves — even before the
187
+ // `terminal.pty.started` event arrives through SSE. Fall back to whatever the
188
+ // event projection found (a PTY opened elsewhere, or a caller-pinned ptyId).
189
+ const activePtyId = openedPtyId ?? openPty;
190
+ // An interactively-opened PTY accepts stdin by construction (we only open it on
191
+ // a pty-capable backend); a projected PTY uses its advertised supportsInput.
192
+ const canWrite = openedPtyId !== null || supportsInput;
193
+
194
+ const write = useMemo(() => {
195
+ if (!activePtyId || !canWrite || !sessionId) return null;
196
+ return (data: string) => {
197
+ void client.terminalPtyWrite(workspaceId, sessionId, { ptyId: activePtyId, data }).catch((cause) => {
198
+ // The PTY exec-session was lost on the live box (409/404 — the box rolled
199
+ // over since the open). Self-heal: drop the stale id and re-open a fresh
200
+ // PTY against the current box rather than silently swallowing keystrokes.
201
+ if (
202
+ cause instanceof OpenGeniApiError &&
203
+ (cause.status === 409 || cause.status === 404) &&
204
+ activePtyId === openedPtyId
205
+ ) {
206
+ setOpenedPtyId(null);
207
+ setReopenNonce((n) => n + 1);
208
+ }
209
+ });
210
+ };
211
+ }, [client, workspaceId, sessionId, activePtyId, canWrite, openedPtyId]);
212
+
213
+ const close = useCallback(() => {
214
+ if (!activePtyId || !sessionId) return;
215
+ void client.terminalPtyClose(workspaceId, sessionId, { ptyId: activePtyId }).catch(() => {});
216
+ }, [client, workspaceId, sessionId, activePtyId]);
217
+
218
+ return {
219
+ chunks,
220
+ running: activePtyId !== null,
221
+ write,
222
+ activePtyId,
223
+ close,
224
+ error: openError,
225
+ };
226
+ }