@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.
Files changed (85) hide show
  1. package/README.md +1 -1
  2. package/dist/{chunk-5C7RGAWA.js → chunk-NFYVQWIB.js} +170 -64
  3. package/dist/chunk-NFYVQWIB.js.map +1 -0
  4. package/dist/index.d.ts +791 -145
  5. package/dist/index.js +5447 -2213
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-BeZ3bD3t.d.ts → machines-CnlMb7E-.d.ts} +5 -5
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +1 -1
  10. package/package.json +17 -12
  11. package/src/approvals.ts +16 -4
  12. package/src/client.ts +32 -5
  13. package/src/commands/index.ts +1 -7
  14. package/src/commands/registry.ts +46 -12
  15. package/src/components/chat-composer.tsx +135 -39
  16. package/src/components/code-editor.tsx +28 -36
  17. package/src/components/command-palette.tsx +26 -5
  18. package/src/components/desktop-viewer.tsx +29 -16
  19. package/src/components/diff-view.tsx +27 -189
  20. package/src/components/enrollment-consent.tsx +28 -10
  21. package/src/components/enrollment-device-flow.tsx +8 -5
  22. package/src/components/file-browser.tsx +190 -56
  23. package/src/components/fleet-tile.tsx +13 -4
  24. package/src/components/machine-card.tsx +16 -4
  25. package/src/components/machine-dock-bar.tsx +20 -8
  26. package/src/components/machine-metrics.tsx +31 -8
  27. package/src/components/machine-status-pill.tsx +26 -5
  28. package/src/components/machines-dashboard.tsx +8 -2
  29. package/src/components/markdown.tsx +39 -9
  30. package/src/components/message-timeline.tsx +633 -109
  31. package/src/components/pierre-diff.tsx +83 -15
  32. package/src/components/pierre-file.tsx +10 -9
  33. package/src/components/sandbox-files.tsx +110 -227
  34. package/src/components/sandbox-terminal.tsx +256 -88
  35. package/src/components/sandbox-workspace.tsx +823 -0
  36. package/src/components/session-status.tsx +28 -2
  37. package/src/components/workbench-changes.tsx +541 -0
  38. package/src/components/workspace-dock.tsx +85 -22
  39. package/src/hooks/internal.ts +5 -2
  40. package/src/hooks/use-available-models.ts +7 -2
  41. package/src/hooks/use-billing-usage.ts +4 -1
  42. package/src/hooks/use-codex-accounts.ts +74 -44
  43. package/src/hooks/use-composer.ts +57 -36
  44. package/src/hooks/use-environments.ts +92 -47
  45. package/src/hooks/use-file-attachments.ts +101 -55
  46. package/src/hooks/use-goal.ts +52 -16
  47. package/src/hooks/use-machine-chip.ts +134 -0
  48. package/src/hooks/use-machines.ts +34 -16
  49. package/src/hooks/use-packs.ts +24 -19
  50. package/src/hooks/use-relay-frame-stream.ts +5 -2
  51. package/src/hooks/use-rigs.ts +335 -0
  52. package/src/hooks/use-sandbox-files.ts +213 -39
  53. package/src/hooks/use-sandbox-git.ts +188 -11
  54. package/src/hooks/use-sandbox-terminal.ts +18 -14
  55. package/src/hooks/use-scheduled-tasks.ts +10 -2
  56. package/src/hooks/use-session-capabilities.ts +77 -20
  57. package/src/hooks/use-session-control.ts +49 -21
  58. package/src/hooks/use-session-events.ts +48 -20
  59. package/src/hooks/use-session-lineage.ts +119 -0
  60. package/src/hooks/use-session.ts +43 -28
  61. package/src/hooks/use-slash-commands.ts +6 -4
  62. package/src/hooks/use-turn-queue.ts +74 -147
  63. package/src/hooks/use-windowed-sections.ts +204 -0
  64. package/src/hooks/use-workspace-capture.ts +233 -0
  65. package/src/hooks/use-workspace-edit.ts +231 -0
  66. package/src/hooks/use-workspace-sessions.ts +48 -5
  67. package/src/hooks/use-workspaces.ts +18 -15
  68. package/src/index.ts +128 -23
  69. package/src/lib/format.ts +44 -6
  70. package/src/lib/xterm-renderer.ts +65 -0
  71. package/src/lib/xterm-theme.ts +224 -18
  72. package/src/machines.ts +13 -3
  73. package/src/provider.tsx +3 -1
  74. package/src/timeline/activity-rail.tsx +213 -54
  75. package/src/timeline/disclosure-context.tsx +12 -2
  76. package/src/timeline/index.ts +21 -2
  77. package/src/timeline/parsers.ts +44 -10
  78. package/src/timeline/projection.ts +435 -84
  79. package/src/timeline/shared.tsx +111 -24
  80. package/src/timeline/tool-diff.tsx +24 -20
  81. package/src/timeline/tool-renderers.tsx +98 -21
  82. package/src/timeline/turn-summary.tsx +76 -23
  83. package/src/timeline/types.ts +96 -12
  84. package/styles/tokens.css +2 -2
  85. package/dist/chunk-5C7RGAWA.js.map +0 -1
@@ -1,4 +1,9 @@
1
- import type { GitFileDiff, SessionEvent } from "@opengeni/sdk";
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. The
35
- * `git diff` runs in-box (API-direct) and the hunks come back inline. Refreshes
36
- * on `git.changed`.
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
- setDiff(result.files);
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
- if (!enabled) {
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
- void refresh();
86
- }, [enabled, refresh]);
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 { diff, branch, isRepo, ahead, behind, refresh, loading, error };
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.terminalPtyClose(workspaceId, sessionId, { ptyId: res.ptyId }).catch(() => {});
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.terminalPtyWrite(workspaceId, sessionId, { ptyId: activePtyId, data }).catch((cause) => {
198
- // The PTY exec-session was lost on the live box (409/404 — the box rolled
199
- // over since the open). Self-heal: drop the stale id and re-open a fresh
200
- // PTY against the current box rather than silently swallowing keystrokes.
201
- if (
202
- cause instanceof OpenGeniApiError &&
203
- (cause.status === 409 || cause.status === 404) &&
204
- activePtyId === openedPtyId
205
- ) {
206
- setOpenedPtyId(null);
207
- setReopenNonce((n) => n + 1);
208
- }
209
- });
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, { pollIntervalMs: options.pollIntervalMs, enabled: options.enabled });
28
- return { tasks: state.data ?? [], loading: state.loading, error: state.error, refresh: state.refresh };
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
- export type SessionCapabilitiesState = "idle" | "negotiating" | "ready" | "cold" | "error";
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 purely to KEEP THE BOX WARM while the
39
- * structured Files surface is open. Shares the SAME viewer attach as the
40
- * desktop/terminal (one warm box, one holder) and — like the terminal — needs
41
- * NO un-redacted acknowledgment: listing/reading/writing files is the ordinary
42
- * Channel-A control plane, not the pixel plane. Default false: the Files tab
43
- * negotiates read-only and each op pays the cold-box resume (~5s). When true,
44
- * the holder warms the box once and heartbeats it, so subsequent fs ops are
45
- * ~100ms instead of re-resuming the box on every list/write. It folds NO live
46
- * URL (files ride the stateless HTTP plane) it only refcounts liveness.
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 cell.reason === "lease_cold" || cell.reason === "not_provisioned" || cell.reason === null;
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<"unredacted" | "shared" | null>(null);
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 = caps.viewerHeartbeatIntervalMs > 0 ? caps.viewerHeartbeatIntervalMs : 30_000;
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 (cause instanceof OpenGeniApiError && (cause.status === 409 || cause.status === 410)) {
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 surface warms the box for fast Channel-A ops. It folds no live
271
- // URL (files are stateless HTTP) — the attach is purely a liveness refcount,
272
- // so it's wanted whenever the FileSystem capability is advertised at all.
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, { desktop: wantDesktopAttach });
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
- }, [client, workspaceId, sessionId, enabled, attachDesktop, attachTerminal, attachFiles, warmingPollMs, warmingDeadlineMs, nonce]);
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
- /** Interrupt the running turn (the explicit alternative to queueing). */
10
- interrupt: (reason?: string) => Promise<SessionEvent | null>;
11
- interrupting: boolean;
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 control events: interrupt and approval decisions. Pair with
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 interruptMutation = useMutationRunner();
33
- const approvalMutation = useMutationRunner();
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 interrupt = useCallback(
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 interruptMutation.run(() =>
41
- client.interrupt(workspaceId, sessionId, reason !== undefined ? { reason } : {}));
50
+ return await runControl(() =>
51
+ client.pauseSession(workspaceId, sessionId, reason !== undefined ? { reason } : {}),
52
+ );
42
53
  },
43
- [client, workspaceId, sessionId, interruptMutation.run],
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 (approvalId: string, decision: "approve" | "reject", message?: string): Promise<SessionEvent | null> => {
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 approvalMutation.run(() =>
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, approvalMutation.run],
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 = approvalMutation.mutationError ?? interruptMutation.mutationError;
97
+ const error = approvalError ?? controlError;
71
98
  const clearError = useCallback(() => {
72
- interruptMutation.clearMutationError();
73
- approvalMutation.clearMutationError();
74
- }, [interruptMutation.clearMutationError, approvalMutation.clearMutationError]);
99
+ clearControlError();
100
+ clearApprovalError();
101
+ }, [clearControlError, clearApprovalError]);
75
102
 
76
103
  return {
77
- interrupt,
78
- interrupting: interruptMutation.mutating,
104
+ pause,
105
+ resume,
106
+ controlling,
79
107
  approve,
80
108
  reject,
81
- responding: approvalMutation.mutating,
109
+ responding,
82
110
  error,
83
111
  clearError,
84
112
  };