@opengeni/react 0.11.0 → 0.13.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 +1 -1
- package/dist/{chunk-5C7RGAWA.js → chunk-NFYVQWIB.js} +170 -64
- package/dist/chunk-NFYVQWIB.js.map +1 -0
- package/dist/index.d.ts +791 -145
- package/dist/index.js +5447 -2213
- package/dist/index.js.map +1 -1
- package/dist/{machines-BeZ3bD3t.d.ts → machines-CnlMb7E-.d.ts} +5 -5
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +1 -1
- package/package.json +17 -12
- package/src/approvals.ts +16 -4
- package/src/client.ts +32 -5
- package/src/commands/index.ts +1 -7
- package/src/commands/registry.ts +46 -12
- package/src/components/chat-composer.tsx +135 -39
- package/src/components/code-editor.tsx +28 -36
- package/src/components/command-palette.tsx +26 -5
- package/src/components/desktop-viewer.tsx +29 -16
- package/src/components/diff-view.tsx +27 -189
- package/src/components/enrollment-consent.tsx +28 -10
- package/src/components/enrollment-device-flow.tsx +8 -5
- package/src/components/file-browser.tsx +190 -56
- package/src/components/fleet-tile.tsx +13 -4
- package/src/components/machine-card.tsx +16 -4
- package/src/components/machine-dock-bar.tsx +20 -8
- package/src/components/machine-metrics.tsx +31 -8
- package/src/components/machine-status-pill.tsx +26 -5
- package/src/components/machines-dashboard.tsx +8 -2
- package/src/components/markdown.tsx +39 -9
- package/src/components/message-timeline.tsx +633 -109
- package/src/components/pierre-diff.tsx +83 -15
- package/src/components/pierre-file.tsx +10 -9
- package/src/components/sandbox-files.tsx +110 -227
- package/src/components/sandbox-terminal.tsx +256 -88
- package/src/components/sandbox-workspace.tsx +823 -0
- package/src/components/session-status.tsx +28 -2
- package/src/components/workbench-changes.tsx +541 -0
- package/src/components/workspace-dock.tsx +85 -22
- package/src/hooks/internal.ts +5 -2
- package/src/hooks/use-available-models.ts +7 -2
- package/src/hooks/use-billing-usage.ts +4 -1
- package/src/hooks/use-codex-accounts.ts +74 -44
- package/src/hooks/use-composer.ts +57 -36
- package/src/hooks/use-environments.ts +92 -47
- package/src/hooks/use-file-attachments.ts +101 -55
- package/src/hooks/use-goal.ts +52 -16
- package/src/hooks/use-machine-chip.ts +134 -0
- package/src/hooks/use-machines.ts +34 -16
- package/src/hooks/use-packs.ts +24 -19
- package/src/hooks/use-relay-frame-stream.ts +5 -2
- package/src/hooks/use-rigs.ts +335 -0
- package/src/hooks/use-sandbox-files.ts +213 -39
- package/src/hooks/use-sandbox-git.ts +188 -11
- package/src/hooks/use-sandbox-terminal.ts +18 -14
- package/src/hooks/use-scheduled-tasks.ts +10 -2
- package/src/hooks/use-session-capabilities.ts +77 -20
- package/src/hooks/use-session-control.ts +49 -21
- package/src/hooks/use-session-events.ts +48 -20
- package/src/hooks/use-session-lineage.ts +119 -0
- package/src/hooks/use-session.ts +43 -28
- package/src/hooks/use-slash-commands.ts +6 -4
- package/src/hooks/use-turn-queue.ts +74 -147
- package/src/hooks/use-windowed-sections.ts +204 -0
- package/src/hooks/use-workspace-capture.ts +233 -0
- package/src/hooks/use-workspace-edit.ts +231 -0
- package/src/hooks/use-workspace-sessions.ts +48 -5
- package/src/hooks/use-workspaces.ts +18 -15
- package/src/index.ts +128 -23
- package/src/lib/format.ts +44 -6
- package/src/lib/xterm-renderer.ts +65 -0
- package/src/lib/xterm-theme.ts +224 -18
- package/src/machines.ts +13 -3
- package/src/provider.tsx +3 -1
- package/src/timeline/activity-rail.tsx +213 -54
- package/src/timeline/disclosure-context.tsx +12 -2
- package/src/timeline/index.ts +21 -2
- package/src/timeline/parsers.ts +44 -10
- package/src/timeline/projection.ts +435 -84
- package/src/timeline/shared.tsx +111 -24
- package/src/timeline/tool-diff.tsx +24 -20
- package/src/timeline/tool-renderers.tsx +98 -21
- package/src/timeline/turn-summary.tsx +76 -23
- package/src/timeline/types.ts +96 -12
- package/styles/tokens.css +2 -2
- package/dist/chunk-5C7RGAWA.js.map +0 -1
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
GitFileDiff,
|
|
3
|
+
SessionEvent,
|
|
4
|
+
WorkspaceCaptureManifest,
|
|
5
|
+
WorkspaceCaptureRepo,
|
|
6
|
+
} from "@opengeni/sdk";
|
|
2
7
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
8
|
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
4
9
|
|
|
@@ -12,6 +17,12 @@ export type UseSandboxGitOptions = ClientOverride & {
|
|
|
12
17
|
staged?: boolean | undefined;
|
|
13
18
|
/** Hold off the initial fetch. Default true. */
|
|
14
19
|
enabled?: boolean | undefined;
|
|
20
|
+
/** Lease liveness ("warm" | "draining" | "cold"). When NOT warm, the diff is
|
|
21
|
+
* served from the capture (cold/offline) instead of a live `gitDiff` RPC. */
|
|
22
|
+
liveness?: string | undefined;
|
|
23
|
+
/** The latest turn-end capture (from `useWorkspaceCapture`). Supplies the cold
|
|
24
|
+
* diff for this repo. A warm box always wins (live `gitDiff` unchanged). */
|
|
25
|
+
capture?: WorkspaceCaptureManifest | null | undefined;
|
|
15
26
|
};
|
|
16
27
|
|
|
17
28
|
export type UseSandboxGitResult = {
|
|
@@ -24,16 +35,100 @@ export type UseSandboxGitResult = {
|
|
|
24
35
|
ahead: number;
|
|
25
36
|
behind: number;
|
|
26
37
|
refresh: () => Promise<void>;
|
|
38
|
+
/** Which source the diff is served from: the live box or the turn-end capture. */
|
|
39
|
+
source: "live" | "capture" | null;
|
|
40
|
+
/** When the served capture was taken (ISO), when `source === "capture"`. */
|
|
41
|
+
capturedAt: string | null;
|
|
27
42
|
loading: boolean;
|
|
28
43
|
error: Error | null;
|
|
29
44
|
};
|
|
30
45
|
|
|
46
|
+
/** Normalize a repo root for matching ("" and "." both mean the workspace root). */
|
|
47
|
+
function normalizeRoot(root: string): string {
|
|
48
|
+
return root === "." ? "" : root;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Find the capture repo matching `repoPath` (default workspace root). */
|
|
52
|
+
function repoForPath(
|
|
53
|
+
manifest: WorkspaceCaptureManifest,
|
|
54
|
+
repoPath: string,
|
|
55
|
+
): WorkspaceCaptureRepo | null {
|
|
56
|
+
const want = normalizeRoot(repoPath);
|
|
57
|
+
const exact = manifest.repos.find((r) => normalizeRoot(r.root) === want);
|
|
58
|
+
if (exact) return exact;
|
|
59
|
+
// Root-scoped hook against a single-repo capture whose root isn't literally "":
|
|
60
|
+
// fall back to the sole repo so the common single-repo case just works.
|
|
61
|
+
if (want === "" && manifest.repos.length === 1) return manifest.repos[0] ?? null;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Full diff equality for identity preservation. Hunk count/count summaries are
|
|
66
|
+
* not sufficient: an edit can replace text without changing either shape. */
|
|
67
|
+
function sameFileDiff(a: GitFileDiff, b: GitFileDiff): boolean {
|
|
68
|
+
if (
|
|
69
|
+
a.path !== b.path ||
|
|
70
|
+
a.oldPath !== b.oldPath ||
|
|
71
|
+
a.status !== b.status ||
|
|
72
|
+
a.isBinary !== b.isBinary ||
|
|
73
|
+
a.isImage !== b.isImage ||
|
|
74
|
+
a.additions !== b.additions ||
|
|
75
|
+
a.deletions !== b.deletions ||
|
|
76
|
+
a.truncated !== b.truncated ||
|
|
77
|
+
a.hunks.length !== b.hunks.length
|
|
78
|
+
)
|
|
79
|
+
return false;
|
|
80
|
+
|
|
81
|
+
return a.hunks.every((hunk, hunkIndex) => {
|
|
82
|
+
const other = b.hunks[hunkIndex];
|
|
83
|
+
if (
|
|
84
|
+
!other ||
|
|
85
|
+
hunk.oldStart !== other.oldStart ||
|
|
86
|
+
hunk.oldLines !== other.oldLines ||
|
|
87
|
+
hunk.newStart !== other.newStart ||
|
|
88
|
+
hunk.newLines !== other.newLines ||
|
|
89
|
+
hunk.header !== other.header ||
|
|
90
|
+
hunk.lines.length !== other.lines.length
|
|
91
|
+
)
|
|
92
|
+
return false;
|
|
93
|
+
return hunk.lines.every((line, lineIndex) => {
|
|
94
|
+
const otherLine = other.lines[lineIndex];
|
|
95
|
+
return (
|
|
96
|
+
otherLine !== undefined &&
|
|
97
|
+
line.type === otherLine.type &&
|
|
98
|
+
line.oldNo === otherLine.oldNo &&
|
|
99
|
+
line.newNo === otherLine.newNo &&
|
|
100
|
+
line.text === otherLine.text
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Merge a freshly-served diff over the current one, preserving the identity of
|
|
107
|
+
* genuinely unchanged per-file entries so a cold→warm reconcile does NOT
|
|
108
|
+
* remount unchanged file sections — no-flicker (§12-D1). */
|
|
109
|
+
function mergeDiffs(current: GitFileDiff[], next: GitFileDiff[]): GitFileDiff[] {
|
|
110
|
+
if (current.length === 0) return next;
|
|
111
|
+
const byPath = new Map(current.map((d) => [d.path, d] as const));
|
|
112
|
+
let changed = current.length !== next.length;
|
|
113
|
+
const merged = next.map((file, index) => {
|
|
114
|
+
const existing = byPath.get(file.path);
|
|
115
|
+
if (existing && sameFileDiff(existing, file)) {
|
|
116
|
+
if (current[index] !== existing) changed = true;
|
|
117
|
+
return existing;
|
|
118
|
+
}
|
|
119
|
+
changed = true;
|
|
120
|
+
return file;
|
|
121
|
+
});
|
|
122
|
+
return changed ? merged : current;
|
|
123
|
+
}
|
|
124
|
+
|
|
31
125
|
/**
|
|
32
126
|
* Project the Git service into the Pierre diff data contract: structured
|
|
33
127
|
* `GitFileDiff[]` (per-file hunks with per-line old/new numbers, rename
|
|
34
|
-
* detection, binary flag, add/del counts) plus branch + ahead/behind.
|
|
35
|
-
* `git diff` runs in-box (API-direct)
|
|
36
|
-
*
|
|
128
|
+
* detection, binary flag, add/del counts) plus branch + ahead/behind. When the
|
|
129
|
+
* box is warm the `git diff` runs in-box (API-direct); when it is cold/offline the
|
|
130
|
+
* diff is served from the turn-end capture instead (dossier §10.4). Refreshes on
|
|
131
|
+
* `git.changed`; reconciles live in place on the cold→warm transition.
|
|
37
132
|
*/
|
|
38
133
|
export function useSandboxGit(
|
|
39
134
|
sessionId: string | null | undefined,
|
|
@@ -43,50 +138,121 @@ export function useSandboxGit(
|
|
|
43
138
|
const enabled = (options.enabled ?? true) && Boolean(sessionId);
|
|
44
139
|
const repoPath = options.repoPath ?? "";
|
|
45
140
|
const staged = options.staged ?? false;
|
|
141
|
+
const capture = options.capture ?? null;
|
|
142
|
+
const isLive = options.liveness === "warm" || options.liveness === "draining";
|
|
46
143
|
|
|
47
144
|
const [diff, setDiff] = useState<GitFileDiff[]>([]);
|
|
48
145
|
const [branch, setBranch] = useState<string | null>(null);
|
|
49
146
|
const [isRepo, setIsRepo] = useState(false);
|
|
50
147
|
const [ahead, setAhead] = useState(0);
|
|
51
148
|
const [behind, setBehind] = useState(0);
|
|
149
|
+
const [source, setSource] = useState<"live" | "capture" | null>(null);
|
|
52
150
|
const [loading, setLoading] = useState(false);
|
|
53
151
|
const [error, setError] = useState<Error | null>(null);
|
|
152
|
+
// Live requests and event cursors are identity-scoped. The refs deliberately
|
|
153
|
+
// survive renders, so they must be fenced/reset when that identity changes.
|
|
154
|
+
const refreshGenerationRef = useRef(0);
|
|
155
|
+
const lastChangeRef = useRef(0);
|
|
54
156
|
|
|
55
157
|
const refresh = useCallback(async () => {
|
|
56
158
|
if (!sessionId) return;
|
|
159
|
+
const generation = (refreshGenerationRef.current += 1);
|
|
57
160
|
setLoading(true);
|
|
58
161
|
setError(null);
|
|
59
162
|
try {
|
|
60
163
|
const status = await client.gitStatus(workspaceId, sessionId, { path: repoPath });
|
|
164
|
+
if (refreshGenerationRef.current !== generation) return;
|
|
61
165
|
setIsRepo(status.isRepo);
|
|
62
166
|
setBranch(status.head);
|
|
63
167
|
setAhead(status.ahead);
|
|
64
168
|
setBehind(status.behind);
|
|
65
169
|
if (!status.isRepo) {
|
|
66
170
|
setDiff([]);
|
|
171
|
+
setSource("live");
|
|
67
172
|
return;
|
|
68
173
|
}
|
|
69
174
|
const result = await client.gitDiff(workspaceId, sessionId, { path: repoPath, staged });
|
|
70
|
-
|
|
175
|
+
if (refreshGenerationRef.current !== generation) return;
|
|
176
|
+
// Merge in place so the cold→warm swap keeps unchanged file sections mounted.
|
|
177
|
+
setDiff((prev) => mergeDiffs(prev, result.files));
|
|
178
|
+
setSource("live");
|
|
71
179
|
} catch (cause) {
|
|
180
|
+
if (refreshGenerationRef.current !== generation) return;
|
|
72
181
|
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
73
182
|
} finally {
|
|
74
|
-
setLoading(false);
|
|
183
|
+
if (refreshGenerationRef.current === generation) setLoading(false);
|
|
75
184
|
}
|
|
76
185
|
}, [client, workspaceId, sessionId, repoPath, staged]);
|
|
77
186
|
|
|
187
|
+
// Serve the diff from a capture (the cold/offline source). `staged` has no
|
|
188
|
+
// meaning here: the capture records the combined turn-end change surface vs HEAD.
|
|
189
|
+
const seedFromCapture = useCallback(
|
|
190
|
+
(manifest: WorkspaceCaptureManifest) => {
|
|
191
|
+
const repo = repoForPath(manifest, repoPath);
|
|
192
|
+
if (!repo) {
|
|
193
|
+
setIsRepo(false);
|
|
194
|
+
setBranch(null);
|
|
195
|
+
setAhead(0);
|
|
196
|
+
setBehind(0);
|
|
197
|
+
setDiff((prev) => (prev.length === 0 ? prev : []));
|
|
198
|
+
setSource("capture");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
setIsRepo(true);
|
|
202
|
+
setBranch(repo.head);
|
|
203
|
+
setAhead(repo.ahead);
|
|
204
|
+
setBehind(repo.behind);
|
|
205
|
+
setDiff((prev) => mergeDiffs(prev, repo.diff));
|
|
206
|
+
setSource("capture");
|
|
207
|
+
setError(null);
|
|
208
|
+
},
|
|
209
|
+
[repoPath],
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// Source selection on mount / liveness / capture-revision change. Key on the
|
|
213
|
+
// capture REVISION (a primitive), not the manifest object, so a fresh object per
|
|
214
|
+
// render can't spin the effect; the latest manifest is read from a ref.
|
|
215
|
+
const captureRef = useRef<WorkspaceCaptureManifest | null>(capture);
|
|
216
|
+
captureRef.current = capture;
|
|
217
|
+
const captureRevision = capture?.revision ?? null;
|
|
218
|
+
const identityKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${repoPath}\u0000${staged}`;
|
|
219
|
+
const previousIdentityRef = useRef(identityKey);
|
|
78
220
|
useEffect(() => {
|
|
79
|
-
|
|
221
|
+
// A liveness/capture source change supersedes any older live request. Only a
|
|
222
|
+
// true data-identity change clears rendered state and the event high-water mark.
|
|
223
|
+
refreshGenerationRef.current += 1;
|
|
224
|
+
const identityChanged = previousIdentityRef.current !== identityKey;
|
|
225
|
+
previousIdentityRef.current = identityKey;
|
|
226
|
+
if (identityChanged || !enabled) {
|
|
227
|
+
lastChangeRef.current = 0;
|
|
80
228
|
setDiff([]);
|
|
81
229
|
setIsRepo(false);
|
|
82
230
|
setBranch(null);
|
|
231
|
+
setAhead(0);
|
|
232
|
+
setBehind(0);
|
|
233
|
+
setSource(null);
|
|
234
|
+
setLoading(false);
|
|
235
|
+
setError(null);
|
|
236
|
+
}
|
|
237
|
+
if (!enabled) {
|
|
83
238
|
return;
|
|
84
239
|
}
|
|
85
|
-
|
|
86
|
-
|
|
240
|
+
if (isLive) {
|
|
241
|
+
void refresh();
|
|
242
|
+
} else if (captureRevision !== null && captureRef.current) {
|
|
243
|
+
seedFromCapture(captureRef.current);
|
|
244
|
+
} else {
|
|
245
|
+
void refresh();
|
|
246
|
+
}
|
|
247
|
+
return () => {
|
|
248
|
+
refreshGenerationRef.current += 1;
|
|
249
|
+
};
|
|
250
|
+
}, [enabled, isLive, captureRevision, identityKey, refresh, seedFromCapture]);
|
|
87
251
|
|
|
252
|
+
// git.changed → re-fetch the LIVE diff. A git.changed only originates from a
|
|
253
|
+
// live box, so this both keeps warm sessions fresh and folds a cold box that
|
|
254
|
+
// just came up (unchanged from the pre-capture behavior).
|
|
88
255
|
const events = options.events;
|
|
89
|
-
const lastChangeRef = useRef(0);
|
|
90
256
|
useEffect(() => {
|
|
91
257
|
if (!enabled || !events) return;
|
|
92
258
|
let latest = lastChangeRef.current;
|
|
@@ -101,5 +267,16 @@ export function useSandboxGit(
|
|
|
101
267
|
}
|
|
102
268
|
}, [enabled, events, refresh]);
|
|
103
269
|
|
|
104
|
-
return {
|
|
270
|
+
return {
|
|
271
|
+
diff,
|
|
272
|
+
branch,
|
|
273
|
+
isRepo,
|
|
274
|
+
ahead,
|
|
275
|
+
behind,
|
|
276
|
+
refresh,
|
|
277
|
+
source,
|
|
278
|
+
capturedAt: source === "capture" ? (capture?.capturedAt ?? null) : null,
|
|
279
|
+
loading,
|
|
280
|
+
error,
|
|
281
|
+
};
|
|
105
282
|
}
|
|
@@ -108,7 +108,9 @@ export function useSandboxTerminal(
|
|
|
108
108
|
.then((res) => {
|
|
109
109
|
if (cancelled) {
|
|
110
110
|
// Raced past unmount — close the orphan we just opened.
|
|
111
|
-
void client
|
|
111
|
+
void client
|
|
112
|
+
.terminalPtyClose(workspaceId, sessionId, { ptyId: res.ptyId })
|
|
113
|
+
.catch(() => {});
|
|
112
114
|
return;
|
|
113
115
|
}
|
|
114
116
|
openedId = res.ptyId;
|
|
@@ -194,19 +196,21 @@ export function useSandboxTerminal(
|
|
|
194
196
|
const write = useMemo(() => {
|
|
195
197
|
if (!activePtyId || !canWrite || !sessionId) return null;
|
|
196
198
|
return (data: string) => {
|
|
197
|
-
void client
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
(
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
199
|
+
void client
|
|
200
|
+
.terminalPtyWrite(workspaceId, sessionId, { ptyId: activePtyId, data })
|
|
201
|
+
.catch((cause) => {
|
|
202
|
+
// The PTY exec-session was lost on the live box (409/404 — the box rolled
|
|
203
|
+
// over since the open). Self-heal: drop the stale id and re-open a fresh
|
|
204
|
+
// PTY against the current box rather than silently swallowing keystrokes.
|
|
205
|
+
if (
|
|
206
|
+
cause instanceof OpenGeniApiError &&
|
|
207
|
+
(cause.status === 409 || cause.status === 404) &&
|
|
208
|
+
activePtyId === openedPtyId
|
|
209
|
+
) {
|
|
210
|
+
setOpenedPtyId(null);
|
|
211
|
+
setReopenNonce((n) => n + 1);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
210
214
|
};
|
|
211
215
|
}, [client, workspaceId, sessionId, activePtyId, canWrite, openedPtyId]);
|
|
212
216
|
|
|
@@ -24,6 +24,14 @@ export function useScheduledTasks(options: UseScheduledTasksOptions = {}): UseSc
|
|
|
24
24
|
async () => await client.listScheduledTasks(workspaceId, limit !== undefined ? { limit } : {}),
|
|
25
25
|
[client, workspaceId, limit],
|
|
26
26
|
);
|
|
27
|
-
const state = usePolledValue(load, {
|
|
28
|
-
|
|
27
|
+
const state = usePolledValue(load, {
|
|
28
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
29
|
+
enabled: options.enabled,
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
tasks: state.data ?? [],
|
|
33
|
+
loading: state.loading,
|
|
34
|
+
error: state.error,
|
|
35
|
+
refresh: state.refresh,
|
|
36
|
+
};
|
|
29
37
|
}
|
|
@@ -8,7 +8,21 @@ import {
|
|
|
8
8
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
9
9
|
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// "on-demand" is the boxless-benign resting state: the lease is cold and NOTHING
|
|
12
|
+
// asked to warm a box (no viewer/consent action, no files-tab warm). Under lazy
|
|
13
|
+
// provisioning a chat-only turn never creates a box, so this is the correct calm
|
|
14
|
+
// terminal state — NOT an error. It is distinct from "cold", which now means "a
|
|
15
|
+
// warm-up is genuinely in flight" (a viewer attach was requested) and therefore
|
|
16
|
+
// still polls with a patience deadline. A box appears on demand when the agent
|
|
17
|
+
// first runs a sandbox tool; the workbench observes `sandbox.provision` and
|
|
18
|
+
// renegotiates to pick up the freshly-warm box.
|
|
19
|
+
export type SessionCapabilitiesState =
|
|
20
|
+
| "idle"
|
|
21
|
+
| "negotiating"
|
|
22
|
+
| "ready"
|
|
23
|
+
| "cold"
|
|
24
|
+
| "on-demand"
|
|
25
|
+
| "error";
|
|
12
26
|
|
|
13
27
|
export type UseSessionCapabilitiesOptions = ClientOverride & {
|
|
14
28
|
/**
|
|
@@ -35,15 +49,18 @@ export type UseSessionCapabilitiesOptions = ClientOverride & {
|
|
|
35
49
|
*/
|
|
36
50
|
attachTerminal?: boolean | undefined;
|
|
37
51
|
/**
|
|
38
|
-
* Whether to acquire a viewer holder
|
|
39
|
-
*
|
|
40
|
-
* desktop/terminal (one warm box, one
|
|
41
|
-
* NO un-redacted acknowledgment:
|
|
42
|
-
* Channel-A control plane, not the pixel
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
52
|
+
* Whether to acquire a viewer holder to warm the box for a FILE WRITE — the
|
|
53
|
+
* wake-on-edit INTENT (a first keystroke in the editor), NOT passive browsing.
|
|
54
|
+
* Shares the SAME viewer attach as the desktop/terminal (one warm box, one
|
|
55
|
+
* holder) and — like the terminal — needs NO un-redacted acknowledgment:
|
|
56
|
+
* reading/writing files is the ordinary Channel-A control plane, not the pixel
|
|
57
|
+
* plane. It folds NO live URL (files ride the stateless HTTP plane) — it only
|
|
58
|
+
* refcounts liveness. Unlike desktop/terminal it warms even a COLD box: an edit
|
|
59
|
+
* legitimately cold-creates (that IS the wake), so the write lands ~100ms warm
|
|
60
|
+
* instead of paying the ~5s cold resume. Default false. IMPORTANT: merely
|
|
61
|
+
* opening/reading the Files tab must NOT set this — browsing capture-served
|
|
62
|
+
* trees/diffs needs no box, and warming one on a cold glance burns box-hours to
|
|
63
|
+
* serve reads the capture already answers for free.
|
|
47
64
|
*/
|
|
48
65
|
attachFiles?: boolean | undefined;
|
|
49
66
|
/** Hold off negotiating (e.g. the workbench panel is collapsed). Default true. */
|
|
@@ -103,7 +120,9 @@ function desktopAttachable(cell: SessionCapabilities["DesktopStream"]): boolean
|
|
|
103
120
|
function terminalAttachable(cell: SessionCapabilities["Terminal"]): boolean {
|
|
104
121
|
if (cell.transport === null) {
|
|
105
122
|
// No terminal at all unless the only blocker is a transient cold state.
|
|
106
|
-
return
|
|
123
|
+
return (
|
|
124
|
+
cell.reason === "lease_cold" || cell.reason === "not_provisioned" || cell.reason === null
|
|
125
|
+
);
|
|
107
126
|
}
|
|
108
127
|
// Already pty-ws (warm) is fine to (re)attach; sse-events is the cold state the
|
|
109
128
|
// attach upgrades. Either way it's attachable.
|
|
@@ -147,7 +166,9 @@ export function useSessionCapabilities(
|
|
|
147
166
|
const [capabilities, setCapabilities] = useState<SessionCapabilities | null>(null);
|
|
148
167
|
const [state, setState] = useState<SessionCapabilitiesState>("idle");
|
|
149
168
|
const [error, setError] = useState<Error | null>(null);
|
|
150
|
-
const [acknowledgmentRequired, setAcknowledgmentRequired] = useState<
|
|
169
|
+
const [acknowledgmentRequired, setAcknowledgmentRequired] = useState<
|
|
170
|
+
"unredacted" | "shared" | null
|
|
171
|
+
>(null);
|
|
151
172
|
const [viewerCapReached, setViewerCapReached] = useState(false);
|
|
152
173
|
const [viewerId, setViewerId] = useState<string | null>(null);
|
|
153
174
|
// Bumped to force a fresh negotiation cycle.
|
|
@@ -191,7 +212,8 @@ export function useSessionCapabilities(
|
|
|
191
212
|
|
|
192
213
|
const startHeartbeat = (caps: SessionCapabilities) => {
|
|
193
214
|
if (!localViewerId || heartbeatTimer !== null) return;
|
|
194
|
-
const intervalMs =
|
|
215
|
+
const intervalMs =
|
|
216
|
+
caps.viewerHeartbeatIntervalMs > 0 ? caps.viewerHeartbeatIntervalMs : 30_000;
|
|
195
217
|
heartbeatTimer = setInterval(() => {
|
|
196
218
|
if (cancelled || !localViewerId) return;
|
|
197
219
|
void client
|
|
@@ -205,7 +227,10 @@ export function useSessionCapabilities(
|
|
|
205
227
|
})
|
|
206
228
|
.catch((cause) => {
|
|
207
229
|
if (cancelled) return;
|
|
208
|
-
if (
|
|
230
|
+
if (
|
|
231
|
+
cause instanceof OpenGeniApiError &&
|
|
232
|
+
(cause.status === 409 || cause.status === 410)
|
|
233
|
+
) {
|
|
209
234
|
renegotiate();
|
|
210
235
|
}
|
|
211
236
|
});
|
|
@@ -267,9 +292,14 @@ export function useSessionCapabilities(
|
|
|
267
292
|
// mint the URLs. Only a genuinely-unsupported reason suppresses the attach.
|
|
268
293
|
const wantDesktopAttach = attachDesktop && desktopAttachable(caps.DesktopStream);
|
|
269
294
|
const wantTerminalAttach = attachTerminal && terminalAttachable(caps.Terminal);
|
|
270
|
-
// The Files
|
|
271
|
-
// URL (files are stateless HTTP) — the attach is purely a
|
|
272
|
-
//
|
|
295
|
+
// The Files attach warms the box for a file WRITE (wake-on-edit intent). It
|
|
296
|
+
// folds no live URL (files are stateless HTTP) — the attach is purely a
|
|
297
|
+
// liveness refcount. Unlike the previous "keep the box warm while the Files
|
|
298
|
+
// tab is on screen" wiring, this fires on genuine EDIT intent only, so it
|
|
299
|
+
// legitimately cold-CREATES: an edit is the wake, and the caller (the dock)
|
|
300
|
+
// never sets it for passive browsing. Gate solely on the FileSystem cap
|
|
301
|
+
// being advertised; liveness (cold included) does not suppress it — POST
|
|
302
|
+
// /viewers warms a cold box exactly as it does for a desktop/terminal engage.
|
|
273
303
|
const wantFilesAttach = attachFiles && caps.FileSystem.available;
|
|
274
304
|
if (wantDesktopAttach || wantTerminalAttach || wantFilesAttach) {
|
|
275
305
|
try {
|
|
@@ -277,7 +307,9 @@ export function useSessionCapabilities(
|
|
|
277
307
|
// un-redacted pixel plane (which carries the consent gate); a
|
|
278
308
|
// terminal-only attach (`desktop:false`) warms the box + mints the
|
|
279
309
|
// pty-ws terminal cell WITHOUT tripping the desktop consent 409.
|
|
280
|
-
const holder = await client.attachViewer(workspaceId, sessionId, {
|
|
310
|
+
const holder = await client.attachViewer(workspaceId, sessionId, {
|
|
311
|
+
desktop: wantDesktopAttach,
|
|
312
|
+
});
|
|
281
313
|
if (cancelled) return;
|
|
282
314
|
localViewerId = holder.viewerId;
|
|
283
315
|
viewerIdRef.current = holder.viewerId;
|
|
@@ -348,12 +380,26 @@ export function useSessionCapabilities(
|
|
|
348
380
|
}
|
|
349
381
|
}
|
|
350
382
|
|
|
383
|
+
// Whether ANYTHING asked to warm a box this negotiation. Only then is a
|
|
384
|
+
// cold lease "warming in flight" — worth polling, and worth surfacing a
|
|
385
|
+
// stall as an error. With no warm-up requested a cold lease is the benign
|
|
386
|
+
// boxless resting state (below), never an error.
|
|
387
|
+
const warmUpRequested = wantDesktopAttach || wantTerminalAttach || wantFilesAttach;
|
|
351
388
|
if (caps.liveness === "warm" || caps.liveness === "draining" || localViewerId) {
|
|
352
389
|
setState("ready");
|
|
353
390
|
startHeartbeat(caps);
|
|
354
|
-
} else {
|
|
391
|
+
} else if (warmUpRequested) {
|
|
392
|
+
// A warm-up is genuinely in flight (a viewer attach was requested) but the
|
|
393
|
+
// box isn't warm yet — poll until it warms, with the patience deadline so a
|
|
394
|
+
// real stall surfaces as an error.
|
|
355
395
|
setState("cold");
|
|
356
396
|
pollUntilWarm();
|
|
397
|
+
} else {
|
|
398
|
+
// Boxless and nobody asked for a box: rest in the benign on-demand state.
|
|
399
|
+
// NO poll, NO deadline, NO false "sandbox offline". The box is created on
|
|
400
|
+
// demand when the agent first runs a sandbox tool; the workbench watches
|
|
401
|
+
// for `sandbox.provision` and renegotiates to pick the warm box back up.
|
|
402
|
+
setState("on-demand");
|
|
357
403
|
}
|
|
358
404
|
} catch (cause) {
|
|
359
405
|
if (cancelled) return;
|
|
@@ -378,7 +424,18 @@ export function useSessionCapabilities(
|
|
|
378
424
|
}
|
|
379
425
|
};
|
|
380
426
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
381
|
-
}, [
|
|
427
|
+
}, [
|
|
428
|
+
client,
|
|
429
|
+
workspaceId,
|
|
430
|
+
sessionId,
|
|
431
|
+
enabled,
|
|
432
|
+
attachDesktop,
|
|
433
|
+
attachTerminal,
|
|
434
|
+
attachFiles,
|
|
435
|
+
warmingPollMs,
|
|
436
|
+
warmingDeadlineMs,
|
|
437
|
+
nonce,
|
|
438
|
+
]);
|
|
382
439
|
|
|
383
440
|
// ── Fold stream.url.rotated from the live event log (no round trip) ──────────
|
|
384
441
|
const events = options.events;
|
|
@@ -6,9 +6,9 @@ import { useMutationRunner } from "./internal";
|
|
|
6
6
|
export type UseSessionControlOptions = ClientOverride;
|
|
7
7
|
|
|
8
8
|
export type UseSessionControlResult = {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
pause: (reason?: string) => Promise<SessionEvent | null>;
|
|
10
|
+
resume: (reason?: string) => Promise<SessionEvent | null>;
|
|
11
|
+
controlling: boolean;
|
|
12
12
|
/** Approve a pending `requires_action` approval. */
|
|
13
13
|
approve: (approvalId: string, message?: string) => Promise<SessionEvent | null>;
|
|
14
14
|
/** Reject a pending `requires_action` approval. */
|
|
@@ -20,7 +20,7 @@ export type UseSessionControlResult = {
|
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* Session
|
|
23
|
+
* Session pause/resume and approval decisions. Pair with
|
|
24
24
|
* `useSessionEvents` (for `session.requiresAction` payloads carrying the
|
|
25
25
|
* `approvalId`) to render an approval bar.
|
|
26
26
|
*/
|
|
@@ -29,33 +29,60 @@ export function useSessionControl(
|
|
|
29
29
|
options: UseSessionControlOptions = {},
|
|
30
30
|
): UseSessionControlResult {
|
|
31
31
|
const { client, workspaceId } = useOpenGeni(options);
|
|
32
|
-
const
|
|
33
|
-
|
|
32
|
+
const {
|
|
33
|
+
run: runControl,
|
|
34
|
+
mutating: controlling,
|
|
35
|
+
mutationError: controlError,
|
|
36
|
+
clearMutationError: clearControlError,
|
|
37
|
+
} = useMutationRunner();
|
|
38
|
+
const {
|
|
39
|
+
run: runApproval,
|
|
40
|
+
mutating: responding,
|
|
41
|
+
mutationError: approvalError,
|
|
42
|
+
clearMutationError: clearApprovalError,
|
|
43
|
+
} = useMutationRunner();
|
|
34
44
|
|
|
35
|
-
const
|
|
45
|
+
const pause = useCallback(
|
|
36
46
|
async (reason?: string): Promise<SessionEvent | null> => {
|
|
37
47
|
if (!sessionId) {
|
|
38
48
|
return null;
|
|
39
49
|
}
|
|
40
|
-
return await
|
|
41
|
-
client.
|
|
50
|
+
return await runControl(() =>
|
|
51
|
+
client.pauseSession(workspaceId, sessionId, reason !== undefined ? { reason } : {}),
|
|
52
|
+
);
|
|
42
53
|
},
|
|
43
|
-
[client, workspaceId, sessionId,
|
|
54
|
+
[client, workspaceId, sessionId, runControl],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const resume = useCallback(
|
|
58
|
+
async (reason?: string): Promise<SessionEvent | null> => {
|
|
59
|
+
if (!sessionId) return null;
|
|
60
|
+
const result = await runControl(() =>
|
|
61
|
+
client.resumeSession(workspaceId, sessionId, reason !== undefined ? { reason } : {}),
|
|
62
|
+
);
|
|
63
|
+
return result?.event ?? null;
|
|
64
|
+
},
|
|
65
|
+
[client, workspaceId, sessionId, runControl],
|
|
44
66
|
);
|
|
45
67
|
|
|
46
68
|
const decide = useCallback(
|
|
47
|
-
async (
|
|
69
|
+
async (
|
|
70
|
+
approvalId: string,
|
|
71
|
+
decision: "approve" | "reject",
|
|
72
|
+
message?: string,
|
|
73
|
+
): Promise<SessionEvent | null> => {
|
|
48
74
|
if (!sessionId) {
|
|
49
75
|
return null;
|
|
50
76
|
}
|
|
51
|
-
return await
|
|
77
|
+
return await runApproval(() =>
|
|
52
78
|
client.sendApprovalDecision(workspaceId, sessionId, {
|
|
53
79
|
approvalId,
|
|
54
80
|
decision,
|
|
55
81
|
...(message !== undefined ? { message } : {}),
|
|
56
|
-
})
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
57
84
|
},
|
|
58
|
-
[client, workspaceId, sessionId,
|
|
85
|
+
[client, workspaceId, sessionId, runApproval],
|
|
59
86
|
);
|
|
60
87
|
|
|
61
88
|
const approve = useCallback(
|
|
@@ -67,18 +94,19 @@ export function useSessionControl(
|
|
|
67
94
|
[decide],
|
|
68
95
|
);
|
|
69
96
|
|
|
70
|
-
const error =
|
|
97
|
+
const error = approvalError ?? controlError;
|
|
71
98
|
const clearError = useCallback(() => {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}, [
|
|
99
|
+
clearControlError();
|
|
100
|
+
clearApprovalError();
|
|
101
|
+
}, [clearControlError, clearApprovalError]);
|
|
75
102
|
|
|
76
103
|
return {
|
|
77
|
-
|
|
78
|
-
|
|
104
|
+
pause,
|
|
105
|
+
resume,
|
|
106
|
+
controlling,
|
|
79
107
|
approve,
|
|
80
108
|
reject,
|
|
81
|
-
responding
|
|
109
|
+
responding,
|
|
82
110
|
error,
|
|
83
111
|
clearError,
|
|
84
112
|
};
|