@opengeni/react 0.15.0 → 0.20.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 +110 -10
- package/dist/{chunk-TOJR776I.js → chunk-6BXBGJ3B.js} +45 -369
- package/dist/chunk-6BXBGJ3B.js.map +1 -0
- package/dist/chunk-6UT5OC3P.js +2133 -0
- package/dist/chunk-6UT5OC3P.js.map +1 -0
- package/dist/chunk-EBTT3MYN.js +92 -0
- package/dist/chunk-EBTT3MYN.js.map +1 -0
- package/dist/chunk-HHFA4AFX.js +425 -0
- package/dist/chunk-HHFA4AFX.js.map +1 -0
- package/dist/chunk-LAZ2A743.js +2393 -0
- package/dist/chunk-LAZ2A743.js.map +1 -0
- package/dist/chunk-MRSECXRP.js +563 -0
- package/dist/chunk-MRSECXRP.js.map +1 -0
- package/dist/chunk-TK7G6XLT.js +18 -0
- package/dist/chunk-TK7G6XLT.js.map +1 -0
- package/dist/composer-DvUaa5ki.d.ts +585 -0
- package/dist/composer.d.ts +6 -0
- package/dist/composer.js +56 -0
- package/dist/composer.js.map +1 -0
- package/dist/index.d.ts +1288 -1785
- package/dist/index.js +7015 -8806
- package/dist/index.js.map +1 -1
- package/dist/machines.d.ts +412 -3
- package/dist/machines.js +3 -1
- package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
- package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
- package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
- package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
- package/dist/queue-surface-implementation-NQMV2ML3.js +890 -0
- package/dist/queue-surface-implementation-NQMV2ML3.js.map +1 -0
- package/dist/session-Gd4iVPYw.d.ts +425 -0
- package/dist/session-context-DIFFlXKc.d.ts +52 -0
- package/dist/session.d.ts +4 -0
- package/dist/session.js +50 -0
- package/dist/session.js.map +1 -0
- package/dist/use-composer-BCfm4mzX.d.ts +112 -0
- package/package.json +20 -4
- package/src/approvals.ts +5 -3
- package/src/client.ts +40 -0
- package/src/components/approval-surface.tsx +198 -0
- package/src/components/chat-composer.tsx +94 -993
- package/src/components/code-editor.tsx +104 -26
- package/src/components/command-palette.tsx +8 -2
- package/src/components/composer-transcription-control.tsx +211 -0
- package/src/components/composer.tsx +1536 -0
- package/src/components/desktop-viewer.tsx +3 -3
- package/src/components/enrollment-consent.tsx +2 -2
- package/src/components/file-browser.tsx +367 -29
- package/src/components/human-input-form.tsx +417 -0
- package/src/components/machine-card.tsx +1 -1
- package/src/components/machines-dashboard.tsx +3 -3
- package/src/components/message-timeline.tsx +15 -7
- package/src/components/model-picker.tsx +12 -3
- package/src/components/pierre-diff.tsx +32 -6
- package/src/components/queue-surface-implementation.tsx +1041 -0
- package/src/components/queue-surface-state.tsx +94 -0
- package/src/components/queue-surface.tsx +41 -559
- package/src/components/sandbox-files.tsx +138 -14
- package/src/components/sandbox-terminal.tsx +8 -2
- package/src/components/sandbox-workspace.tsx +339 -119
- package/src/components/workbench-changes.tsx +84 -33
- package/src/components/workspace-dock.tsx +188 -18
- package/src/composer.ts +60 -0
- package/src/hooks/internal.ts +115 -34
- package/src/hooks/use-composer.ts +335 -104
- package/src/hooks/use-human-input.ts +131 -0
- package/src/hooks/use-machine-chip.ts +2 -2
- package/src/hooks/use-machines.ts +27 -11
- package/src/hooks/use-sandbox-files.ts +310 -62
- package/src/hooks/use-sandbox-git.ts +154 -40
- package/src/hooks/use-sandbox-terminal.ts +28 -6
- package/src/hooks/use-session-capabilities.ts +38 -9
- package/src/hooks/use-session-control.ts +37 -8
- package/src/hooks/use-session-events.ts +522 -48
- package/src/hooks/use-slash-commands.ts +38 -38
- package/src/hooks/use-transcription.ts +757 -0
- package/src/hooks/use-turn-queue.ts +134 -66
- package/src/hooks/use-windowed-sections.ts +2 -2
- package/src/hooks/use-workspace-capture.ts +146 -40
- package/src/hooks/use-workspace-edit.ts +50 -14
- package/src/hooks/use-workspace-sessions.ts +3 -0
- package/src/human-input.ts +72 -0
- package/src/index.ts +76 -6
- package/src/lib/noto-sans-arabic-loader.ts +21 -0
- package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
- package/src/lib/use-portal-token-style.ts +51 -0
- package/src/lib/use-unicode-fonts.ts +57 -0
- package/src/provider.tsx +4 -69
- package/src/session-context.ts +135 -0
- package/src/session.ts +79 -0
- package/src/timeline/parsers.ts +35 -0
- package/src/timeline/projection.ts +12 -4
- package/src/timeline/tool-renderers.tsx +41 -0
- package/src/timeline/turn-summary.tsx +2 -2
- package/src/timeline/types.ts +16 -8
- package/styles/index.css +56 -0
- package/styles/index.d.ts +2 -0
- package/styles/tokens.css +11 -6
- package/styles/tokens.d.ts +2 -0
- package/dist/chunk-TOJR776I.js.map +0 -1
- package/dist/machines-BpdwuQcD.d.ts +0 -449
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
WorkspaceCaptureManifest,
|
|
5
5
|
WorkspaceCaptureRepo,
|
|
6
6
|
} from "@opengeni/sdk";
|
|
7
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
7
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
8
8
|
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
9
9
|
|
|
10
10
|
export type UseSandboxGitOptions = ClientOverride & {
|
|
@@ -13,6 +13,9 @@ export type UseSandboxGitOptions = ClientOverride & {
|
|
|
13
13
|
events?: SessionEvent[] | undefined;
|
|
14
14
|
/** Repo root within the workspace (multi-repo). Default: workspace root. */
|
|
15
15
|
repoPath?: string | undefined;
|
|
16
|
+
/** All repository roots in the workspace. When supplied, status and diff are
|
|
17
|
+
* aggregated into one workspace-wide result and paths are workspace-relative. */
|
|
18
|
+
repoPaths?: readonly string[] | undefined;
|
|
16
19
|
/** Diff the staged index vs HEAD (`--cached`) instead of the working tree. */
|
|
17
20
|
staged?: boolean | undefined;
|
|
18
21
|
/** Hold off the initial fetch. Default true. */
|
|
@@ -20,20 +23,29 @@ export type UseSandboxGitOptions = ClientOverride & {
|
|
|
20
23
|
/** Lease liveness ("warm" | "draining" | "cold"). When NOT warm, the diff is
|
|
21
24
|
* served from the capture (cold/offline) instead of a live `gitDiff` RPC. */
|
|
22
25
|
liveness?: string | undefined;
|
|
23
|
-
/** The latest turn-end capture (from `useWorkspaceCapture`).
|
|
24
|
-
*
|
|
26
|
+
/** The latest turn-end capture (from `useWorkspaceCapture`). Seeds the diff
|
|
27
|
+
* immediately; a warm box reconciles live and leaves this durable review
|
|
28
|
+
* surface in place if the live request is temporarily unavailable. */
|
|
25
29
|
capture?: WorkspaceCaptureManifest | null | undefined;
|
|
26
30
|
};
|
|
27
31
|
|
|
32
|
+
/** A Git diff qualified into workspace scope. `repoRoot` is present for the
|
|
33
|
+
* workspace-wide path and lets review UI group real repositories without
|
|
34
|
+
* guessing from the first path segment. */
|
|
35
|
+
export type SandboxGitFileDiff = GitFileDiff & { repoRoot?: string | undefined };
|
|
36
|
+
|
|
28
37
|
export type UseSandboxGitResult = {
|
|
29
38
|
/** Working-tree (or staged) diff vs HEAD — the structured hunks the Pierre
|
|
30
39
|
* diff view renders. */
|
|
31
|
-
diff:
|
|
40
|
+
diff: SandboxGitFileDiff[];
|
|
32
41
|
branch: string | null;
|
|
33
42
|
/** Whether a repo is actually mounted (drives "no repository" vs "no changes"). */
|
|
34
43
|
isRepo: boolean;
|
|
35
44
|
ahead: number;
|
|
36
45
|
behind: number;
|
|
46
|
+
/** Number and workspace-relative roots of repositories represented here. */
|
|
47
|
+
repoCount: number;
|
|
48
|
+
repoRoots: string[];
|
|
37
49
|
refresh: () => Promise<void>;
|
|
38
50
|
/** Which source the diff is served from: the live box or the turn-end capture. */
|
|
39
51
|
source: "live" | "capture" | null;
|
|
@@ -45,7 +57,30 @@ export type UseSandboxGitResult = {
|
|
|
45
57
|
|
|
46
58
|
/** Normalize a repo root for matching ("" and "." both mean the workspace root). */
|
|
47
59
|
function normalizeRoot(root: string): string {
|
|
48
|
-
|
|
60
|
+
const normalized = root
|
|
61
|
+
.replaceAll("\\", "/")
|
|
62
|
+
.replace(/^\.\/+/, "")
|
|
63
|
+
.replace(/\/+$/, "");
|
|
64
|
+
return normalized === "." ? "" : normalized;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function qualifyPath(repoRoot: string, path: string): string {
|
|
68
|
+
const root = normalizeRoot(repoRoot);
|
|
69
|
+
return root ? `${root}/${path}` : path;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function qualifyDiff(repoRoot: string, file: GitFileDiff): SandboxGitFileDiff {
|
|
73
|
+
const root = normalizeRoot(repoRoot);
|
|
74
|
+
return {
|
|
75
|
+
...file,
|
|
76
|
+
path: qualifyPath(root, file.path),
|
|
77
|
+
oldPath: file.oldPath === null ? null : qualifyPath(root, file.oldPath),
|
|
78
|
+
repoRoot: root,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function uniqueRoots(roots: readonly string[]): string[] {
|
|
83
|
+
return [...new Set(roots.map(normalizeRoot))].sort((a, b) => a.localeCompare(b));
|
|
49
84
|
}
|
|
50
85
|
|
|
51
86
|
/** Find the capture repo matching `repoPath` (default workspace root). */
|
|
@@ -64,10 +99,11 @@ function repoForPath(
|
|
|
64
99
|
|
|
65
100
|
/** Full diff equality for identity preservation. Hunk count/count summaries are
|
|
66
101
|
* not sufficient: an edit can replace text without changing either shape. */
|
|
67
|
-
function sameFileDiff(a:
|
|
102
|
+
function sameFileDiff(a: SandboxGitFileDiff, b: SandboxGitFileDiff): boolean {
|
|
68
103
|
if (
|
|
69
104
|
a.path !== b.path ||
|
|
70
105
|
a.oldPath !== b.oldPath ||
|
|
106
|
+
a.repoRoot !== b.repoRoot ||
|
|
71
107
|
a.status !== b.status ||
|
|
72
108
|
a.isBinary !== b.isBinary ||
|
|
73
109
|
a.isImage !== b.isImage ||
|
|
@@ -106,7 +142,10 @@ function sameFileDiff(a: GitFileDiff, b: GitFileDiff): boolean {
|
|
|
106
142
|
/** Merge a freshly-served diff over the current one, preserving the identity of
|
|
107
143
|
* genuinely unchanged per-file entries so a cold→warm reconcile does NOT
|
|
108
144
|
* remount unchanged file sections — no-flicker (§12-D1). */
|
|
109
|
-
function mergeDiffs(
|
|
145
|
+
function mergeDiffs(
|
|
146
|
+
current: SandboxGitFileDiff[],
|
|
147
|
+
next: SandboxGitFileDiff[],
|
|
148
|
+
): SandboxGitFileDiff[] {
|
|
110
149
|
if (current.length === 0) return next;
|
|
111
150
|
const byPath = new Map(current.map((d) => [d.path, d] as const));
|
|
112
151
|
let changed = current.length !== next.length;
|
|
@@ -127,7 +166,7 @@ function mergeDiffs(current: GitFileDiff[], next: GitFileDiff[]): GitFileDiff[]
|
|
|
127
166
|
* `GitFileDiff[]` (per-file hunks with per-line old/new numbers, rename
|
|
128
167
|
* detection, binary flag, add/del counts) plus branch + ahead/behind. When the
|
|
129
168
|
* 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
|
|
169
|
+
* diff is served from the turn-end capture instead. Refreshes on
|
|
131
170
|
* `git.changed`; reconciles live in place on the cold→warm transition.
|
|
132
171
|
*/
|
|
133
172
|
export function useSandboxGit(
|
|
@@ -137,76 +176,141 @@ export function useSandboxGit(
|
|
|
137
176
|
const { client, workspaceId } = useOpenGeni(options);
|
|
138
177
|
const enabled = (options.enabled ?? true) && Boolean(sessionId);
|
|
139
178
|
const repoPath = options.repoPath ?? "";
|
|
179
|
+
const workspaceWide = options.repoPaths !== undefined;
|
|
180
|
+
// Serialize the caller's array so inline values do not retrigger effects. An
|
|
181
|
+
// explicitly empty advertised list falls back to the legacy repoPath probe.
|
|
182
|
+
const repoPathsKey = uniqueRoots(
|
|
183
|
+
options.repoPaths && options.repoPaths.length > 0 ? options.repoPaths : [repoPath],
|
|
184
|
+
).join("\u0000");
|
|
185
|
+
const repoPaths = useMemo(() => repoPathsKey.split("\u0000"), [repoPathsKey]);
|
|
140
186
|
const staged = options.staged ?? false;
|
|
141
187
|
const capture = options.capture ?? null;
|
|
142
188
|
const isLive = options.liveness === "warm" || options.liveness === "draining";
|
|
189
|
+
const identityKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${repoPathsKey}\u0000${workspaceWide}\u0000${staged}`;
|
|
143
190
|
|
|
144
|
-
const [diff, setDiff] = useState<
|
|
191
|
+
const [diff, setDiff] = useState<SandboxGitFileDiff[]>([]);
|
|
145
192
|
const [branch, setBranch] = useState<string | null>(null);
|
|
146
193
|
const [isRepo, setIsRepo] = useState(false);
|
|
147
194
|
const [ahead, setAhead] = useState(0);
|
|
148
195
|
const [behind, setBehind] = useState(0);
|
|
196
|
+
const [repoRoots, setRepoRoots] = useState<string[]>([]);
|
|
149
197
|
const [source, setSource] = useState<"live" | "capture" | null>(null);
|
|
198
|
+
const sourceRef = useRef<"live" | "capture" | null>(null);
|
|
150
199
|
const [loading, setLoading] = useState(false);
|
|
151
200
|
const [error, setError] = useState<Error | null>(null);
|
|
201
|
+
const [stateIdentity, setStateIdentity] = useState(identityKey);
|
|
152
202
|
// Live requests and event cursors are identity-scoped. The refs deliberately
|
|
153
203
|
// survive renders, so they must be fenced/reset when that identity changes.
|
|
154
204
|
const refreshGenerationRef = useRef(0);
|
|
205
|
+
const refreshAbortRef = useRef<AbortController | null>(null);
|
|
155
206
|
const lastChangeRef = useRef(0);
|
|
156
207
|
|
|
157
208
|
const refresh = useCallback(async () => {
|
|
158
209
|
if (!sessionId) return;
|
|
210
|
+
refreshAbortRef.current?.abort();
|
|
211
|
+
const refreshAbort = new AbortController();
|
|
212
|
+
refreshAbortRef.current = refreshAbort;
|
|
159
213
|
const generation = (refreshGenerationRef.current += 1);
|
|
160
214
|
setLoading(true);
|
|
161
215
|
setError(null);
|
|
162
216
|
try {
|
|
163
|
-
const
|
|
217
|
+
const statuses = await Promise.all(
|
|
218
|
+
repoPaths.map(async (root) => ({
|
|
219
|
+
root,
|
|
220
|
+
status: await client.gitStatus(
|
|
221
|
+
workspaceId,
|
|
222
|
+
sessionId,
|
|
223
|
+
{ path: root },
|
|
224
|
+
{ signal: refreshAbort.signal },
|
|
225
|
+
),
|
|
226
|
+
})),
|
|
227
|
+
);
|
|
164
228
|
if (refreshGenerationRef.current !== generation) return;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
229
|
+
const repositories = statuses.filter(({ status }) => status.isRepo);
|
|
230
|
+
const roots = repositories.map(({ root }) => root);
|
|
231
|
+
setRepoRoots(roots);
|
|
232
|
+
setIsRepo(repositories.length > 0);
|
|
233
|
+
setBranch(repositories.length === 1 ? (repositories[0]?.status.head ?? null) : null);
|
|
234
|
+
setAhead(repositories.reduce((sum, { status }) => sum + status.ahead, 0));
|
|
235
|
+
setBehind(repositories.reduce((sum, { status }) => sum + status.behind, 0));
|
|
236
|
+
if (repositories.length === 0) {
|
|
170
237
|
setDiff([]);
|
|
238
|
+
sourceRef.current = "live";
|
|
171
239
|
setSource("live");
|
|
172
240
|
return;
|
|
173
241
|
}
|
|
174
|
-
const
|
|
242
|
+
const results = await Promise.all(
|
|
243
|
+
repositories.map(async ({ root }) => ({
|
|
244
|
+
root,
|
|
245
|
+
result: await client.gitDiff(
|
|
246
|
+
workspaceId,
|
|
247
|
+
sessionId,
|
|
248
|
+
{
|
|
249
|
+
path: root,
|
|
250
|
+
staged,
|
|
251
|
+
includeUntracked: !staged,
|
|
252
|
+
},
|
|
253
|
+
{ signal: refreshAbort.signal },
|
|
254
|
+
),
|
|
255
|
+
})),
|
|
256
|
+
);
|
|
175
257
|
if (refreshGenerationRef.current !== generation) return;
|
|
176
258
|
// Merge in place so the cold→warm swap keeps unchanged file sections mounted.
|
|
177
|
-
setDiff((prev) =>
|
|
259
|
+
setDiff((prev) =>
|
|
260
|
+
mergeDiffs(
|
|
261
|
+
prev,
|
|
262
|
+
results.flatMap(({ root, result }) =>
|
|
263
|
+
workspaceWide ? result.files.map((file) => qualifyDiff(root, file)) : result.files,
|
|
264
|
+
),
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
sourceRef.current = "live";
|
|
178
268
|
setSource("live");
|
|
179
269
|
} catch (cause) {
|
|
180
270
|
if (refreshGenerationRef.current !== generation) return;
|
|
181
271
|
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
182
272
|
} finally {
|
|
183
273
|
if (refreshGenerationRef.current === generation) setLoading(false);
|
|
274
|
+
if (refreshAbortRef.current === refreshAbort) refreshAbortRef.current = null;
|
|
184
275
|
}
|
|
185
|
-
}, [client, workspaceId, sessionId,
|
|
276
|
+
}, [client, workspaceId, sessionId, repoPaths, staged, workspaceWide]);
|
|
186
277
|
|
|
187
278
|
// Serve the diff from a capture (the cold/offline source). `staged` has no
|
|
188
279
|
// meaning here: the capture records the combined turn-end change surface vs HEAD.
|
|
189
280
|
const seedFromCapture = useCallback(
|
|
190
281
|
(manifest: WorkspaceCaptureManifest) => {
|
|
191
|
-
const
|
|
192
|
-
|
|
282
|
+
const repositories = workspaceWide
|
|
283
|
+
? manifest.repos
|
|
284
|
+
: ([repoForPath(manifest, repoPath)].filter(Boolean) as WorkspaceCaptureRepo[]);
|
|
285
|
+
if (repositories.length === 0) {
|
|
193
286
|
setIsRepo(false);
|
|
194
287
|
setBranch(null);
|
|
195
288
|
setAhead(0);
|
|
196
289
|
setBehind(0);
|
|
290
|
+
setRepoRoots([]);
|
|
197
291
|
setDiff((prev) => (prev.length === 0 ? prev : []));
|
|
292
|
+
sourceRef.current = "capture";
|
|
198
293
|
setSource("capture");
|
|
199
294
|
return;
|
|
200
295
|
}
|
|
201
296
|
setIsRepo(true);
|
|
202
|
-
setBranch(
|
|
203
|
-
setAhead(repo.ahead);
|
|
204
|
-
setBehind(repo.behind);
|
|
205
|
-
|
|
297
|
+
setBranch(repositories.length === 1 ? (repositories[0]?.head ?? null) : null);
|
|
298
|
+
setAhead(repositories.reduce((sum, repo) => sum + repo.ahead, 0));
|
|
299
|
+
setBehind(repositories.reduce((sum, repo) => sum + repo.behind, 0));
|
|
300
|
+
setRepoRoots(repositories.map((repo) => normalizeRoot(repo.root)));
|
|
301
|
+
setDiff((prev) =>
|
|
302
|
+
mergeDiffs(
|
|
303
|
+
prev,
|
|
304
|
+
repositories.flatMap((repo) =>
|
|
305
|
+
workspaceWide ? repo.diff.map((file) => qualifyDiff(repo.root, file)) : repo.diff,
|
|
306
|
+
),
|
|
307
|
+
),
|
|
308
|
+
);
|
|
309
|
+
sourceRef.current = "capture";
|
|
206
310
|
setSource("capture");
|
|
207
311
|
setError(null);
|
|
208
312
|
},
|
|
209
|
-
[repoPath],
|
|
313
|
+
[repoPath, workspaceWide],
|
|
210
314
|
);
|
|
211
315
|
|
|
212
316
|
// Source selection on mount / liveness / capture-revision change. Key on the
|
|
@@ -215,21 +319,25 @@ export function useSandboxGit(
|
|
|
215
319
|
const captureRef = useRef<WorkspaceCaptureManifest | null>(capture);
|
|
216
320
|
captureRef.current = capture;
|
|
217
321
|
const captureRevision = capture?.revision ?? null;
|
|
218
|
-
const identityKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${repoPath}\u0000${staged}`;
|
|
219
322
|
const previousIdentityRef = useRef(identityKey);
|
|
220
323
|
useEffect(() => {
|
|
221
324
|
// A liveness/capture source change supersedes any older live request. Only a
|
|
222
325
|
// true data-identity change clears rendered state and the event high-water mark.
|
|
326
|
+
refreshAbortRef.current?.abort();
|
|
327
|
+
refreshAbortRef.current = null;
|
|
223
328
|
refreshGenerationRef.current += 1;
|
|
224
329
|
const identityChanged = previousIdentityRef.current !== identityKey;
|
|
225
330
|
previousIdentityRef.current = identityKey;
|
|
226
331
|
if (identityChanged || !enabled) {
|
|
227
332
|
lastChangeRef.current = 0;
|
|
228
333
|
setDiff([]);
|
|
334
|
+
setStateIdentity(identityKey);
|
|
229
335
|
setIsRepo(false);
|
|
230
336
|
setBranch(null);
|
|
231
337
|
setAhead(0);
|
|
232
338
|
setBehind(0);
|
|
339
|
+
setRepoRoots([]);
|
|
340
|
+
sourceRef.current = null;
|
|
233
341
|
setSource(null);
|
|
234
342
|
setLoading(false);
|
|
235
343
|
setError(null);
|
|
@@ -237,14 +345,16 @@ export function useSandboxGit(
|
|
|
237
345
|
if (!enabled) {
|
|
238
346
|
return;
|
|
239
347
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
348
|
+
const currentCapture = captureRevision !== null ? captureRef.current : null;
|
|
349
|
+
if (currentCapture && (identityChanged || !isLive || sourceRef.current !== "live")) {
|
|
350
|
+
seedFromCapture(currentCapture);
|
|
351
|
+
}
|
|
352
|
+
if (isLive || !currentCapture) {
|
|
245
353
|
void refresh();
|
|
246
354
|
}
|
|
247
355
|
return () => {
|
|
356
|
+
refreshAbortRef.current?.abort();
|
|
357
|
+
refreshAbortRef.current = null;
|
|
248
358
|
refreshGenerationRef.current += 1;
|
|
249
359
|
};
|
|
250
360
|
}, [enabled, isLive, captureRevision, identityKey, refresh, seedFromCapture]);
|
|
@@ -267,16 +377,20 @@ export function useSandboxGit(
|
|
|
267
377
|
}
|
|
268
378
|
}, [enabled, events, refresh]);
|
|
269
379
|
|
|
380
|
+
const identityMatches = enabled && stateIdentity === identityKey;
|
|
381
|
+
const visibleRepoRoots = identityMatches ? repoRoots : [];
|
|
270
382
|
return {
|
|
271
|
-
diff,
|
|
272
|
-
branch,
|
|
273
|
-
isRepo,
|
|
274
|
-
ahead,
|
|
275
|
-
behind,
|
|
383
|
+
diff: identityMatches ? diff : [],
|
|
384
|
+
branch: identityMatches ? branch : null,
|
|
385
|
+
isRepo: identityMatches && isRepo,
|
|
386
|
+
ahead: identityMatches ? ahead : 0,
|
|
387
|
+
behind: identityMatches ? behind : 0,
|
|
388
|
+
repoCount: visibleRepoRoots.length,
|
|
389
|
+
repoRoots: visibleRepoRoots,
|
|
276
390
|
refresh,
|
|
277
|
-
source,
|
|
278
|
-
capturedAt: source === "capture" ? (capture?.capturedAt ?? null) : null,
|
|
279
|
-
loading,
|
|
280
|
-
error,
|
|
391
|
+
source: identityMatches ? source : null,
|
|
392
|
+
capturedAt: identityMatches && source === "capture" ? (capture?.capturedAt ?? null) : null,
|
|
393
|
+
loading: identityMatches && loading,
|
|
394
|
+
error: identityMatches ? error : null,
|
|
281
395
|
};
|
|
282
396
|
}
|
|
@@ -82,12 +82,24 @@ export function useSandboxTerminal(
|
|
|
82
82
|
// is live before the `terminal.pty.started` event round-trips through SSE.
|
|
83
83
|
const ptyFilter = options.ptyId;
|
|
84
84
|
const liveness = options.liveness;
|
|
85
|
+
const identityKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${interactive}\u0000${ptyFilter ?? ""}`;
|
|
85
86
|
const [openedPtyId, setOpenedPtyId] = useState<string | null>(null);
|
|
86
87
|
const [openError, setOpenError] = useState<Error | null>(null);
|
|
88
|
+
const [stateIdentity, setStateIdentity] = useState(identityKey);
|
|
87
89
|
// Bumped to force a fresh PTY open after a write reveals the old PTY was lost
|
|
88
90
|
// (a 409 "pty session lost" — the box rolled over since the open). This makes
|
|
89
91
|
// the interactive terminal self-heal instead of dead-ending on a stale session.
|
|
90
92
|
const [reopenNonce, setReopenNonce] = useState(0);
|
|
93
|
+
const currentIdentityRef = useRef(identityKey);
|
|
94
|
+
currentIdentityRef.current = identityKey;
|
|
95
|
+
|
|
96
|
+
// Async PTY state belongs to one workspace/session/mode identity. Hide it
|
|
97
|
+
// synchronously on the switching render, then clear it durably in the effect.
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
setStateIdentity(identityKey);
|
|
100
|
+
setOpenedPtyId(null);
|
|
101
|
+
setOpenError(null);
|
|
102
|
+
}, [identityKey]);
|
|
91
103
|
|
|
92
104
|
// Open ONE interactive PTY against the box, close it on unmount/identity
|
|
93
105
|
// change. The open's banner + subsequent output deltas ride A1, so xterm fills
|
|
@@ -99,8 +111,12 @@ export function useSandboxTerminal(
|
|
|
99
111
|
const openInFlight = useRef(false);
|
|
100
112
|
const boxWarm = liveness === undefined || liveness === "warm" || liveness === "draining";
|
|
101
113
|
useEffect(() => {
|
|
102
|
-
if (!interactive || !sessionId || ptyFilter || !boxWarm)
|
|
114
|
+
if (!interactive || !sessionId || ptyFilter || !boxWarm) {
|
|
115
|
+
setOpenError(null);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
103
118
|
let cancelled = false;
|
|
119
|
+
setOpenError(null);
|
|
104
120
|
openInFlight.current = true;
|
|
105
121
|
let openedId: string | null = null;
|
|
106
122
|
void client
|
|
@@ -138,6 +154,9 @@ export function useSandboxTerminal(
|
|
|
138
154
|
let lastSupportsInput = false;
|
|
139
155
|
|
|
140
156
|
for (const event of options.events) {
|
|
157
|
+
// The hook is session-scoped even if a host accidentally retains the old
|
|
158
|
+
// event array for one render during navigation.
|
|
159
|
+
if (event.workspaceId !== workspaceId || event.sessionId !== sessionId) continue;
|
|
141
160
|
if (event.type === "terminal.pty.started") {
|
|
142
161
|
const payload = event.payload as { ptyId?: string } | null;
|
|
143
162
|
if (payload?.ptyId) {
|
|
@@ -182,19 +201,21 @@ export function useSandboxTerminal(
|
|
|
182
201
|
const activePty = ptyFilter && open.has(ptyFilter) ? ptyFilter : lastOpened;
|
|
183
202
|
lastSupportsInput = activePty ? (open.get(activePty)?.supportsInput ?? false) : false;
|
|
184
203
|
return { chunks: out, openPty: activePty, supportsInput: lastSupportsInput };
|
|
185
|
-
}, [options.events, ptyFilter, includeAgentFirehose]);
|
|
204
|
+
}, [options.events, ptyFilter, includeAgentFirehose, workspaceId, sessionId]);
|
|
186
205
|
|
|
187
206
|
// The PTY this hook actively drives: the one it opened (interactive) wins so
|
|
188
207
|
// `write` is live the instant the open resolves — even before the
|
|
189
208
|
// `terminal.pty.started` event arrives through SSE. Fall back to whatever the
|
|
190
209
|
// event projection found (a PTY opened elsewhere, or a caller-pinned ptyId).
|
|
191
|
-
const
|
|
210
|
+
const identityMatches = stateIdentity === identityKey;
|
|
211
|
+
const activePtyId = identityMatches ? (openedPtyId ?? openPty) : null;
|
|
192
212
|
// An interactively-opened PTY accepts stdin by construction (we only open it on
|
|
193
213
|
// a pty-capable backend); a projected PTY uses its advertised supportsInput.
|
|
194
214
|
const canWrite = openedPtyId !== null || supportsInput;
|
|
195
215
|
|
|
196
216
|
const write = useMemo(() => {
|
|
197
217
|
if (!activePtyId || !canWrite || !sessionId) return null;
|
|
218
|
+
const writeIdentity = identityKey;
|
|
198
219
|
return (data: string) => {
|
|
199
220
|
void client
|
|
200
221
|
.terminalPtyWrite(workspaceId, sessionId, { ptyId: activePtyId, data })
|
|
@@ -205,14 +226,15 @@ export function useSandboxTerminal(
|
|
|
205
226
|
if (
|
|
206
227
|
cause instanceof OpenGeniApiError &&
|
|
207
228
|
(cause.status === 409 || cause.status === 404) &&
|
|
208
|
-
activePtyId === openedPtyId
|
|
229
|
+
activePtyId === openedPtyId &&
|
|
230
|
+
currentIdentityRef.current === writeIdentity
|
|
209
231
|
) {
|
|
210
232
|
setOpenedPtyId(null);
|
|
211
233
|
setReopenNonce((n) => n + 1);
|
|
212
234
|
}
|
|
213
235
|
});
|
|
214
236
|
};
|
|
215
|
-
}, [client, workspaceId, sessionId, activePtyId, canWrite, openedPtyId]);
|
|
237
|
+
}, [client, workspaceId, sessionId, activePtyId, canWrite, openedPtyId, identityKey]);
|
|
216
238
|
|
|
217
239
|
const close = useCallback(() => {
|
|
218
240
|
if (!activePtyId || !sessionId) return;
|
|
@@ -225,6 +247,6 @@ export function useSandboxTerminal(
|
|
|
225
247
|
write,
|
|
226
248
|
activePtyId,
|
|
227
249
|
close,
|
|
228
|
-
error: openError,
|
|
250
|
+
error: identityMatches ? openError : null,
|
|
229
251
|
};
|
|
230
252
|
}
|
|
@@ -162,6 +162,7 @@ export function useSessionCapabilities(
|
|
|
162
162
|
const attachFiles = options.attachFiles ?? false;
|
|
163
163
|
const warmingPollMs = options.warmingPollMs ?? 1500;
|
|
164
164
|
const warmingDeadlineMs = options.warmingDeadlineMs ?? 30_000;
|
|
165
|
+
const identityKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${enabled}`;
|
|
165
166
|
|
|
166
167
|
const [capabilities, setCapabilities] = useState<SessionCapabilities | null>(null);
|
|
167
168
|
const [state, setState] = useState<SessionCapabilitiesState>("idle");
|
|
@@ -171,6 +172,7 @@ export function useSessionCapabilities(
|
|
|
171
172
|
>(null);
|
|
172
173
|
const [viewerCapReached, setViewerCapReached] = useState(false);
|
|
173
174
|
const [viewerId, setViewerId] = useState<string | null>(null);
|
|
175
|
+
const [stateIdentity, setStateIdentity] = useState(identityKey);
|
|
174
176
|
// Bumped to force a fresh negotiation cycle.
|
|
175
177
|
const [nonce, setNonce] = useState(0);
|
|
176
178
|
|
|
@@ -178,6 +180,7 @@ export function useSessionCapabilities(
|
|
|
178
180
|
// from the event log, and echoed on heartbeats.
|
|
179
181
|
const epochRef = useRef(0);
|
|
180
182
|
const viewerIdRef = useRef<string | null>(null);
|
|
183
|
+
const previousIdentityRef = useRef(identityKey);
|
|
181
184
|
|
|
182
185
|
const renegotiate = useCallback(() => {
|
|
183
186
|
setNonce((n) => n + 1);
|
|
@@ -185,6 +188,19 @@ export function useSessionCapabilities(
|
|
|
185
188
|
|
|
186
189
|
// ── Negotiation + viewer lifecycle ──────────────────────────────────────────
|
|
187
190
|
useEffect(() => {
|
|
191
|
+
const identityChanged = previousIdentityRef.current !== identityKey;
|
|
192
|
+
previousIdentityRef.current = identityKey;
|
|
193
|
+
if (identityChanged) {
|
|
194
|
+
setStateIdentity(identityKey);
|
|
195
|
+
setCapabilities(null);
|
|
196
|
+
setState("idle");
|
|
197
|
+
setError(null);
|
|
198
|
+
setAcknowledgmentRequired(null);
|
|
199
|
+
setViewerCapReached(false);
|
|
200
|
+
setViewerId(null);
|
|
201
|
+
epochRef.current = 0;
|
|
202
|
+
viewerIdRef.current = null;
|
|
203
|
+
}
|
|
188
204
|
if (!enabled || !sessionId) {
|
|
189
205
|
setState("idle");
|
|
190
206
|
setCapabilities(null);
|
|
@@ -194,6 +210,7 @@ export function useSessionCapabilities(
|
|
|
194
210
|
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
195
211
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
196
212
|
let localViewerId: string | null = null;
|
|
213
|
+
const requestAbort = new AbortController();
|
|
197
214
|
const startedAt = Date.now();
|
|
198
215
|
|
|
199
216
|
const clearTimers = () => {
|
|
@@ -241,7 +258,7 @@ export function useSessionCapabilities(
|
|
|
241
258
|
pollTimer = setTimeout(() => {
|
|
242
259
|
if (cancelled) return;
|
|
243
260
|
void client
|
|
244
|
-
.getStreamCapabilities(workspaceId, sessionId)
|
|
261
|
+
.getStreamCapabilities(workspaceId, sessionId, { signal: requestAbort.signal })
|
|
245
262
|
.then((caps) => {
|
|
246
263
|
if (cancelled) return;
|
|
247
264
|
settle(caps);
|
|
@@ -270,7 +287,9 @@ export function useSessionCapabilities(
|
|
|
270
287
|
setAcknowledgmentRequired(null);
|
|
271
288
|
setViewerCapReached(false);
|
|
272
289
|
try {
|
|
273
|
-
const caps = await client.getStreamCapabilities(workspaceId, sessionId
|
|
290
|
+
const caps = await client.getStreamCapabilities(workspaceId, sessionId, {
|
|
291
|
+
signal: requestAbort.signal,
|
|
292
|
+
});
|
|
274
293
|
if (cancelled) return;
|
|
275
294
|
settle(caps);
|
|
276
295
|
|
|
@@ -310,7 +329,14 @@ export function useSessionCapabilities(
|
|
|
310
329
|
const holder = await client.attachViewer(workspaceId, sessionId, {
|
|
311
330
|
desktop: wantDesktopAttach,
|
|
312
331
|
});
|
|
313
|
-
if (cancelled)
|
|
332
|
+
if (cancelled) {
|
|
333
|
+
// The attach crossed an identity/unmount boundary after the server
|
|
334
|
+
// had already minted a holder. It is too late to publish the result,
|
|
335
|
+
// but abandoning it would keep the old sandbox warm until the
|
|
336
|
+
// reaper runs. Release that exact old-session holder immediately.
|
|
337
|
+
void client.detachViewer(workspaceId, sessionId, holder.viewerId).catch(() => {});
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
314
340
|
localViewerId = holder.viewerId;
|
|
315
341
|
viewerIdRef.current = holder.viewerId;
|
|
316
342
|
setViewerId(holder.viewerId);
|
|
@@ -415,6 +441,7 @@ export function useSessionCapabilities(
|
|
|
415
441
|
|
|
416
442
|
return () => {
|
|
417
443
|
cancelled = true;
|
|
444
|
+
requestAbort.abort();
|
|
418
445
|
clearTimers();
|
|
419
446
|
// Fire-and-forget detach (idempotent delete-my-row). Capture the id so a
|
|
420
447
|
// re-render/unmount race still releases the right holder.
|
|
@@ -435,6 +462,7 @@ export function useSessionCapabilities(
|
|
|
435
462
|
warmingPollMs,
|
|
436
463
|
warmingDeadlineMs,
|
|
437
464
|
nonce,
|
|
465
|
+
identityKey,
|
|
438
466
|
]);
|
|
439
467
|
|
|
440
468
|
// ── Fold stream.url.rotated from the live event log (no round trip) ──────────
|
|
@@ -461,13 +489,14 @@ export function useSessionCapabilities(
|
|
|
461
489
|
});
|
|
462
490
|
}, [events]);
|
|
463
491
|
|
|
492
|
+
const identityMatches = enabled && stateIdentity === identityKey;
|
|
464
493
|
return {
|
|
465
|
-
capabilities,
|
|
466
|
-
state,
|
|
467
|
-
error,
|
|
468
|
-
acknowledgmentRequired,
|
|
469
|
-
viewerCapReached,
|
|
470
|
-
viewerId,
|
|
494
|
+
capabilities: identityMatches ? capabilities : null,
|
|
495
|
+
state: identityMatches ? state : "idle",
|
|
496
|
+
error: identityMatches ? error : null,
|
|
497
|
+
acknowledgmentRequired: identityMatches ? acknowledgmentRequired : null,
|
|
498
|
+
viewerCapReached: identityMatches && viewerCapReached,
|
|
499
|
+
viewerId: identityMatches ? viewerId : null,
|
|
471
500
|
renegotiate,
|
|
472
501
|
};
|
|
473
502
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { SessionControlResponse, SessionEvent } from "@opengeni/sdk";
|
|
2
|
-
import { useCallback } from "react";
|
|
3
|
-
import {
|
|
2
|
+
import { useCallback, useLayoutEffect, useRef } from "react";
|
|
3
|
+
import { useEmbeddedSession, type EmbeddedSessionClientOverride } from "../session-context";
|
|
4
4
|
import { useMutationRunner } from "./internal";
|
|
5
5
|
|
|
6
|
-
export type UseSessionControlOptions =
|
|
6
|
+
export type UseSessionControlOptions = EmbeddedSessionClientOverride;
|
|
7
7
|
|
|
8
8
|
export type UseSessionControlResult = {
|
|
9
9
|
pause: (reason?: string) => Promise<SessionControlResponse | null>;
|
|
@@ -28,19 +28,30 @@ export function useSessionControl(
|
|
|
28
28
|
sessionId: string | null | undefined,
|
|
29
29
|
options: UseSessionControlOptions = {},
|
|
30
30
|
): UseSessionControlResult {
|
|
31
|
-
const { client, workspaceId } =
|
|
31
|
+
const { client, workspaceId } = useEmbeddedSession(options);
|
|
32
|
+
const approvalTargetKey = `${workspaceId}\u0000${sessionId ?? ""}`;
|
|
33
|
+
const pendingApproval = useRef<{
|
|
34
|
+
targetKey: string;
|
|
35
|
+
decisionKey: string;
|
|
36
|
+
clientEventId: string;
|
|
37
|
+
} | null>(null);
|
|
38
|
+
useLayoutEffect(() => {
|
|
39
|
+
if (pendingApproval.current?.targetKey !== approvalTargetKey) {
|
|
40
|
+
pendingApproval.current = null;
|
|
41
|
+
}
|
|
42
|
+
}, [approvalTargetKey]);
|
|
32
43
|
const {
|
|
33
44
|
run: runControl,
|
|
34
45
|
mutating: controlling,
|
|
35
46
|
mutationError: controlError,
|
|
36
47
|
clearMutationError: clearControlError,
|
|
37
|
-
} = useMutationRunner();
|
|
48
|
+
} = useMutationRunner(approvalTargetKey);
|
|
38
49
|
const {
|
|
39
50
|
run: runApproval,
|
|
40
51
|
mutating: responding,
|
|
41
52
|
mutationError: approvalError,
|
|
42
53
|
clearMutationError: clearApprovalError,
|
|
43
|
-
} = useMutationRunner();
|
|
54
|
+
} = useMutationRunner(approvalTargetKey);
|
|
44
55
|
|
|
45
56
|
const pause = useCallback(
|
|
46
57
|
async (reason?: string): Promise<SessionControlResponse | null> => {
|
|
@@ -73,15 +84,33 @@ export function useSessionControl(
|
|
|
73
84
|
if (!sessionId) {
|
|
74
85
|
return null;
|
|
75
86
|
}
|
|
76
|
-
|
|
87
|
+
const decisionKey = JSON.stringify([approvalId, decision, message ?? null]);
|
|
88
|
+
if (pendingApproval.current?.decisionKey !== decisionKey) {
|
|
89
|
+
pendingApproval.current = {
|
|
90
|
+
targetKey: approvalTargetKey,
|
|
91
|
+
decisionKey,
|
|
92
|
+
clientEventId: crypto.randomUUID(),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const clientEventId = pendingApproval.current.clientEventId;
|
|
96
|
+
const accepted = await runApproval(() =>
|
|
77
97
|
client.sendApprovalDecision(workspaceId, sessionId, {
|
|
78
98
|
approvalId,
|
|
79
99
|
decision,
|
|
80
100
|
...(message !== undefined ? { message } : {}),
|
|
101
|
+
clientEventId,
|
|
81
102
|
}),
|
|
82
103
|
);
|
|
104
|
+
if (
|
|
105
|
+
accepted !== null &&
|
|
106
|
+
pendingApproval.current?.targetKey === approvalTargetKey &&
|
|
107
|
+
pendingApproval.current.clientEventId === clientEventId
|
|
108
|
+
) {
|
|
109
|
+
pendingApproval.current = null;
|
|
110
|
+
}
|
|
111
|
+
return accepted;
|
|
83
112
|
},
|
|
84
|
-
[client, workspaceId, sessionId, runApproval],
|
|
113
|
+
[approvalTargetKey, client, workspaceId, sessionId, runApproval],
|
|
85
114
|
);
|
|
86
115
|
|
|
87
116
|
const approve = useCallback(
|