@opengeni/react 0.15.0 → 0.23.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 (103) hide show
  1. package/README.md +110 -10
  2. package/dist/{chunk-TOJR776I.js → chunk-6XUS5VFM.js} +45 -369
  3. package/dist/chunk-6XUS5VFM.js.map +1 -0
  4. package/dist/chunk-CATATY77.js +2393 -0
  5. package/dist/chunk-CATATY77.js.map +1 -0
  6. package/dist/chunk-I3BJZIG5.js +439 -0
  7. package/dist/chunk-I3BJZIG5.js.map +1 -0
  8. package/dist/chunk-TK7G6XLT.js +18 -0
  9. package/dist/chunk-TK7G6XLT.js.map +1 -0
  10. package/dist/chunk-UAHPKLB6.js +2237 -0
  11. package/dist/chunk-UAHPKLB6.js.map +1 -0
  12. package/dist/chunk-UQS7OQDE.js +591 -0
  13. package/dist/chunk-UQS7OQDE.js.map +1 -0
  14. package/dist/chunk-YDNWMKXO.js +102 -0
  15. package/dist/chunk-YDNWMKXO.js.map +1 -0
  16. package/dist/composer-CVm5uoUI.d.ts +585 -0
  17. package/dist/composer.d.ts +6 -0
  18. package/dist/composer.js +56 -0
  19. package/dist/composer.js.map +1 -0
  20. package/dist/index.d.ts +1288 -1785
  21. package/dist/index.js +7140 -8817
  22. package/dist/index.js.map +1 -1
  23. package/dist/machines.d.ts +412 -3
  24. package/dist/machines.js +3 -1
  25. package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
  26. package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
  27. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
  28. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
  29. package/dist/queue-surface-implementation-3UW3MIIR.js +890 -0
  30. package/dist/queue-surface-implementation-3UW3MIIR.js.map +1 -0
  31. package/dist/session-Db00TRwl.d.ts +446 -0
  32. package/dist/session-context-rgrfElMl.d.ts +58 -0
  33. package/dist/session.d.ts +4 -0
  34. package/dist/session.js +54 -0
  35. package/dist/session.js.map +1 -0
  36. package/dist/use-composer-By1wn3MM.d.ts +99 -0
  37. package/package.json +20 -4
  38. package/src/approvals.ts +5 -3
  39. package/src/client.ts +44 -0
  40. package/src/components/approval-surface.tsx +198 -0
  41. package/src/components/chat-composer.tsx +94 -993
  42. package/src/components/code-editor.tsx +104 -26
  43. package/src/components/command-palette.tsx +8 -2
  44. package/src/components/composer-transcription-control.tsx +211 -0
  45. package/src/components/composer.tsx +1536 -0
  46. package/src/components/desktop-viewer.tsx +3 -3
  47. package/src/components/enrollment-consent.tsx +2 -2
  48. package/src/components/file-browser.tsx +367 -29
  49. package/src/components/human-input-form.tsx +417 -0
  50. package/src/components/machine-card.tsx +1 -1
  51. package/src/components/machines-dashboard.tsx +3 -3
  52. package/src/components/message-timeline.tsx +205 -25
  53. package/src/components/model-picker.tsx +12 -3
  54. package/src/components/pierre-diff.tsx +32 -6
  55. package/src/components/queue-draft-policy.ts +18 -0
  56. package/src/components/queue-surface-implementation.tsx +1037 -0
  57. package/src/components/queue-surface-state.tsx +94 -0
  58. package/src/components/queue-surface.tsx +42 -559
  59. package/src/components/sandbox-files.tsx +138 -14
  60. package/src/components/sandbox-terminal.tsx +8 -2
  61. package/src/components/sandbox-workspace.tsx +339 -119
  62. package/src/components/workbench-changes.tsx +84 -33
  63. package/src/components/workspace-dock.tsx +188 -18
  64. package/src/composer.ts +60 -0
  65. package/src/hooks/internal.ts +115 -34
  66. package/src/hooks/use-composer.ts +385 -104
  67. package/src/hooks/use-human-input.ts +131 -0
  68. package/src/hooks/use-machine-chip.ts +2 -2
  69. package/src/hooks/use-machines.ts +27 -11
  70. package/src/hooks/use-sandbox-files.ts +310 -62
  71. package/src/hooks/use-sandbox-git.ts +154 -40
  72. package/src/hooks/use-sandbox-terminal.ts +28 -6
  73. package/src/hooks/use-session-capabilities.ts +38 -9
  74. package/src/hooks/use-session-control.ts +37 -8
  75. package/src/hooks/use-session-events.ts +522 -48
  76. package/src/hooks/use-session-mcp-approval-policy.ts +161 -0
  77. package/src/hooks/use-slash-commands.ts +38 -38
  78. package/src/hooks/use-transcription.ts +757 -0
  79. package/src/hooks/use-turn-queue.ts +134 -66
  80. package/src/hooks/use-windowed-sections.ts +2 -2
  81. package/src/hooks/use-workspace-capture.ts +146 -40
  82. package/src/hooks/use-workspace-edit.ts +50 -14
  83. package/src/hooks/use-workspace-sessions.ts +3 -0
  84. package/src/human-input.ts +72 -0
  85. package/src/index.ts +85 -6
  86. package/src/lib/noto-sans-arabic-loader.ts +21 -0
  87. package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
  88. package/src/lib/use-portal-token-style.ts +51 -0
  89. package/src/lib/use-unicode-fonts.ts +57 -0
  90. package/src/provider.tsx +4 -69
  91. package/src/session-context.ts +160 -0
  92. package/src/session.ts +89 -0
  93. package/src/timeline/parsers.ts +35 -0
  94. package/src/timeline/projection.ts +12 -4
  95. package/src/timeline/tool-renderers.tsx +41 -0
  96. package/src/timeline/turn-summary.tsx +2 -2
  97. package/src/timeline/types.ts +10 -9
  98. package/styles/index.css +56 -0
  99. package/styles/index.d.ts +2 -0
  100. package/styles/tokens.css +11 -6
  101. package/styles/tokens.d.ts +2 -0
  102. package/dist/chunk-TOJR776I.js.map +0 -1
  103. 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`). Supplies the cold
24
- * diff for this repo. A warm box always wins (live `gitDiff` unchanged). */
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: GitFileDiff[];
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
- return root === "." ? "" : root;
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: GitFileDiff, b: GitFileDiff): boolean {
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(current: GitFileDiff[], next: GitFileDiff[]): GitFileDiff[] {
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 (dossier §10.4). Refreshes on
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<GitFileDiff[]>([]);
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 status = await client.gitStatus(workspaceId, sessionId, { path: repoPath });
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
- setIsRepo(status.isRepo);
166
- setBranch(status.head);
167
- setAhead(status.ahead);
168
- setBehind(status.behind);
169
- if (!status.isRepo) {
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 result = await client.gitDiff(workspaceId, sessionId, { path: repoPath, staged });
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) => mergeDiffs(prev, result.files));
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, repoPath, staged]);
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 repo = repoForPath(manifest, repoPath);
192
- if (!repo) {
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(repo.head);
203
- setAhead(repo.ahead);
204
- setBehind(repo.behind);
205
- setDiff((prev) => mergeDiffs(prev, repo.diff));
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
- if (isLive) {
241
- void refresh();
242
- } else if (captureRevision !== null && captureRef.current) {
243
- seedFromCapture(captureRef.current);
244
- } else {
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) return;
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 activePtyId = openedPtyId ?? openPty;
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) return;
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 { useOpenGeni, type ClientOverride } from "../provider";
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 = ClientOverride;
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 } = useOpenGeni(options);
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
- return await runApproval(() =>
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(