@opengeni/react 0.12.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-5TIXRCSI.js → chunk-NFYVQWIB.js} +150 -64
  3. package/dist/chunk-NFYVQWIB.js.map +1 -0
  4. package/dist/index.d.ts +760 -142
  5. package/dist/index.js +5345 -2146
  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 +127 -23
  69. package/src/lib/format.ts +3 -3
  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 +388 -85
  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-5TIXRCSI.js.map +0 -1
@@ -0,0 +1,823 @@
1
+ // The sandbox workbench — the SDK-embeddable dock "brain" (Workbench v2, M4).
2
+ //
3
+ // This module owns the whole session workspace surface an embedder mounts:
4
+ // Changes — review-first git: turn-end capture (cold) or live diff (warm)
5
+ // Files — tree + inline editor (capture-backed cold, live warm)
6
+ // Terminal — interactive xterm wired to the box PTY (Channel-A projection)
7
+ // Desktop — noVNC (Channel-B), watch by default + a server-gated take-control
8
+ // plus a machine-state chip in the dock header (the one truthful live/waking/
9
+ // offline indicator) and any host-injected extra tabs (Run/Debug in apps/web).
10
+ //
11
+ // It is decoupled from the host app: the client comes from <OpenGeniProvider>
12
+ // (never an app context), notifications flow through an optional `onNotify` prop
13
+ // (no `sonner` import), and every surface renders with package primitives + og
14
+ // tokens only. `apps/web` consumes this through the exact public surface an
15
+ // external embedder (cloudgeni #1577) uses — that is criterion F1.
16
+ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
17
+ import { Popover } from "radix-ui";
18
+ import type { SessionEvent } from "@opengeni/sdk";
19
+ import { CpuIcon, LaptopIcon, RefreshCwIcon } from "lucide-react";
20
+
21
+ import { type ClientOverride, useOpenGeni } from "../provider";
22
+ import { cn } from "../lib/cn";
23
+ import { xtermThemeFromTokens } from "../lib/xterm-theme";
24
+ import { useSessionCapabilities } from "../hooks/use-session-capabilities";
25
+ import { useSandboxFiles } from "../hooks/use-sandbox-files";
26
+ import { useSandboxGit, type UseSandboxGitResult } from "../hooks/use-sandbox-git";
27
+ import { useSandboxTerminal } from "../hooks/use-sandbox-terminal";
28
+ import { useWorkspaceCapture } from "../hooks/use-workspace-capture";
29
+ import { formatAsOf, useMachineChip, type MachineChip } from "../hooks/use-machine-chip";
30
+ import { useMachines } from "../hooks/use-machines";
31
+ import type { MachineView } from "../types/machines";
32
+ import { connectionStatusForState } from "../types/machines";
33
+ import { ConnectionStatusPill } from "./machine-status-pill";
34
+ import { SharedMachineDisclosure } from "./machine-dock-bar";
35
+ import { SandboxFiles } from "./sandbox-files";
36
+ import { WorkbenchChanges } from "./workbench-changes";
37
+ import { SandboxTerminal, type XtermTheme } from "./sandbox-terminal";
38
+ import { DesktopViewer } from "./desktop-viewer";
39
+ import { WorkspaceDock, type WorkspaceDockProps, type WorkspaceTab } from "./workspace-dock";
40
+
41
+ /** A host-routed notification (replaces the app-only `sonner` toast coupling). */
42
+ export type WorkspaceNotification = { kind: "error" | "info"; message: string };
43
+
44
+ /** The workbench's canonical tab ids (a host injects extras around these). */
45
+ export const WORKBENCH_TAB_CHANGES = "changes";
46
+ export const WORKBENCH_TAB_FILES = "files";
47
+
48
+ function captureDegradedMessage(reason: string): string {
49
+ switch (reason) {
50
+ case "repository_discovery_timed_out":
51
+ return "Workspace capture is incomplete because repository discovery timed out. Live files remain authoritative.";
52
+ case "repository_discovery_result_limit_exceeded":
53
+ return "Workspace capture is incomplete because the repository limit was exceeded. Live files remain authoritative.";
54
+ default:
55
+ return "Workspace capture is incomplete because repository discovery failed. Live files remain authoritative.";
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Decide a workspace tab from an already-local event log: the newest
61
+ * `workspace.revision.captured` announce carries the change surface stats, so
62
+ * "changes exist → Changes, else Files" needs zero machine round-trips. A host
63
+ * `override` (e.g. a landing "run" tab) wins when supplied.
64
+ *
65
+ * NOTE: the dock no longer uses this for its default tab — `<SandboxWorkspace>`
66
+ * now derives the default from its OWN capture fetch (`useWorkspaceCapture`'s
67
+ * `fileCount`), so a pure embedder needs no events-at-mount contract (Refinement
68
+ * 2). This remains exported as a standalone helper for hosts that already hold
69
+ * the event log and want the same decision without the capture fetch.
70
+ */
71
+ export function initialWorkspaceTab(
72
+ events: SessionEvent[] | undefined,
73
+ override?: string | null,
74
+ ): string {
75
+ if (override) return override;
76
+ let bestSeq = -1;
77
+ let bestFileCount = 0;
78
+ for (const event of events ?? []) {
79
+ if (event.type !== "workspace.revision.captured") continue;
80
+ if (event.sequence <= bestSeq) continue;
81
+ const stats = (event.payload as { stats?: { fileCount?: number } } | null)?.stats;
82
+ bestSeq = event.sequence;
83
+ bestFileCount = typeof stats?.fileCount === "number" ? stats.fileCount : 0;
84
+ }
85
+ return bestFileCount > 0 ? WORKBENCH_TAB_CHANGES : WORKBENCH_TAB_FILES;
86
+ }
87
+
88
+ /**
89
+ * Whether a lazy sandbox provision is in flight on this event stream: the latest
90
+ * `sandbox.provision` operation event is a `.started` not yet closed by a
91
+ * `.completed`/`.failed`. Package-local twin of apps/web's events helper so the
92
+ * dock brain can wake its on-demand capability negotiation when the box warms.
93
+ */
94
+ function sandboxProvisionInFlight(events: SessionEvent[]): boolean {
95
+ let inFlight = false;
96
+ for (const event of events) {
97
+ if (
98
+ event.type !== "sandbox.operation.started" &&
99
+ event.type !== "sandbox.operation.completed" &&
100
+ event.type !== "sandbox.operation.failed"
101
+ ) {
102
+ continue;
103
+ }
104
+ const payload = event.payload;
105
+ const name =
106
+ payload && typeof payload === "object" && !Array.isArray(payload)
107
+ ? (payload as Record<string, unknown>).name
108
+ : null;
109
+ if (name !== "sandbox.provision") continue;
110
+ if (event.type === "sandbox.operation.started") {
111
+ inFlight = true;
112
+ } else {
113
+ inFlight = false;
114
+ }
115
+ }
116
+ return inFlight;
117
+ }
118
+
119
+ export type WorkspaceMachine = {
120
+ /** The derived live/waking/offline chip model (dossier §3 #10). */
121
+ chip: MachineChip;
122
+ /** The machine these surfaces are bound to (the Modal group box or a
123
+ * self-hosted machine), or null while the fleet is still resolving. */
124
+ activeMachine: MachineView | null;
125
+ loading: boolean;
126
+ error: Error | null;
127
+ refresh: () => Promise<void>;
128
+ };
129
+
130
+ export type UseSandboxWorkspaceTabsOptions = ClientOverride & {
131
+ sessionId: string;
132
+ /** Live event log (usually `useSessionEvents().events`). */
133
+ events: SessionEvent[];
134
+ /** Override the capture-driven default tab (e.g. a host landing tab id). When
135
+ * omitted the workbench picks Changes-vs-Files from its own capture fetch. */
136
+ initialTab?: string | null | undefined;
137
+ /** Host-routed notifications (mutation errors, desktop-consent failures). The
138
+ * package never imports a toast library — the host decides how to surface. */
139
+ onNotify?: ((notification: WorkspaceNotification) => void) | undefined;
140
+ };
141
+
142
+ export type UseSandboxWorkspaceTabsResult = {
143
+ /** Changes | Files | Terminal | Desktop (capability-gated where noted). */
144
+ tabs: WorkspaceTab[];
145
+ /** The capture-driven default tab: Changes when the session has changes, else
146
+ * Files (a host `initialTab` overrides). null during the brief window before the
147
+ * capture GET first resolves (and no host override was given) — consumers render
148
+ * their dock's own first-tab fallback until it latches, which it does ONCE, so it
149
+ * never causes a post-render switch. */
150
+ defaultTab: string | null;
151
+ /** The machine-state model for the dock-header chip. */
152
+ machine: WorkspaceMachine;
153
+ };
154
+
155
+ type SessionWarmIntents = {
156
+ sessionId: string;
157
+ watchDesktop: boolean;
158
+ warmTerminal: boolean;
159
+ warmEdit: boolean;
160
+ };
161
+
162
+ function emptyWarmIntents(sessionId: string): SessionWarmIntents {
163
+ return {
164
+ sessionId,
165
+ watchDesktop: false,
166
+ warmTerminal: false,
167
+ warmEdit: false,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Build the workbench tabs + the machine-chip model for one session. This is the
173
+ * dock "brain": capability negotiation, capture-backed cold reads, prewarm
174
+ * flags, desktop consent, and the xterm theme observer — all package-local.
175
+ */
176
+ export function useSandboxWorkspaceTabs(
177
+ options: UseSandboxWorkspaceTabsOptions,
178
+ ): UseSandboxWorkspaceTabsResult {
179
+ const { client, workspaceId } = useOpenGeni(options);
180
+ const { sessionId, events, onNotify } = options;
181
+ const initialTab = options.initialTab ?? null;
182
+
183
+ // The three — and only three — box-warming INTENTS, each off by default and each
184
+ // flipped true by a genuine user action (never on mount, never on a passive
185
+ // capture glance): desktop watch consent, terminal engagement (`onActivate`), and
186
+ // the first wake-on-edit keystroke in the Files editor. Browsing capture-served
187
+ // Changes/Files warms nothing — that is the whole point of Refinement 1.
188
+ const [storedWarmIntents, setStoredWarmIntents] = useState<SessionWarmIntents>(() =>
189
+ emptyWarmIntents(sessionId),
190
+ );
191
+ const warmIntents =
192
+ storedWarmIntents.sessionId === sessionId ? storedWarmIntents : emptyWarmIntents(sessionId);
193
+ const { watchDesktop, warmTerminal, warmEdit } = warmIntents;
194
+ const requestWarmIntent = useCallback(
195
+ (intent: Exclude<keyof SessionWarmIntents, "sessionId">) => {
196
+ setStoredWarmIntents((previous) => {
197
+ const current = previous.sessionId === sessionId ? previous : emptyWarmIntents(sessionId);
198
+ return current[intent] ? current : { ...current, [intent]: true };
199
+ });
200
+ },
201
+ [sessionId],
202
+ );
203
+
204
+ // The session's machine fleet + the active-sandbox pointer. Drives the header
205
+ // chip (which machine + its connection state). Polls slowly — ambient context.
206
+ const machines = useMachines({
207
+ workspaceId,
208
+ sessionId,
209
+ pollIntervalMs: 8000,
210
+ });
211
+ const activeMachine: MachineView | null =
212
+ machines.machines.find((m) => m.sandboxId === machines.activeSandboxId) ??
213
+ machines.machines.find((m) => m.active) ??
214
+ null;
215
+
216
+ const caps = useSessionCapabilities(sessionId, {
217
+ events,
218
+ attachDesktop: watchDesktop,
219
+ attachTerminal: warmTerminal,
220
+ // Edit intent only — NOT "the Files tab is open". A cold edit warms the box
221
+ // (that is the wake); a cold glance at the tree/diff does not.
222
+ attachFiles: warmEdit,
223
+ });
224
+ const capabilities = caps.capabilities;
225
+ const liveness = capabilities?.liveness;
226
+ const fileSystemOn = capabilities?.FileSystem.available ?? false;
227
+ // The FS is writable only when it's live AND not read-only. A self-hosted box
228
+ // that's offline (or any read-only advertisement) or a capture-served cold tree
229
+ // must not offer create/rename/delete/edit affordances — you cannot mutate a
230
+ // machine you can't reach (dossier §12-A2/C3). Tree-structure ops need a warm
231
+ // writable box; content editing on a cold CLOUD box is the wake-on-edit path in
232
+ // the editor, not tree mutation.
233
+ const fsReadOnly = capabilities?.FileSystem.readOnly ?? false;
234
+ const filesEditable = fileSystemOn && !fsReadOnly;
235
+ const gitOn = capabilities?.Git.available ?? false;
236
+ const terminalOn = (capabilities?.Terminal.transport ?? null) !== null;
237
+ // The REAL interactive terminal is the ttyd pty-ws stream. When it's live the
238
+ // legacy HTTP PTY must NOT run; `useSandboxTerminal` opens its HTTP PTY only as
239
+ // the firehose-mode fallback (a backend without ttyd).
240
+ const ptyWsLive =
241
+ capabilities?.Terminal.transport === "pty-ws" && Boolean(capabilities?.Terminal.url);
242
+ const ptyCapable = (capabilities?.Terminal.ptyCapable ?? false) && !ptyWsLive;
243
+ const terminal = useSandboxTerminal(sessionId, {
244
+ events,
245
+ interactive: ptyCapable,
246
+ liveness,
247
+ });
248
+ const desktopAdvertised =
249
+ (capabilities?.DesktopStream.transport ?? null) !== null ||
250
+ capabilities?.DesktopStream.reason === "lease_cold";
251
+
252
+ // Lazy provisioning (#315) creates the box mid-turn on the first sandbox tool
253
+ // call, emitting sandbox.provision started→completed/failed on the live stream.
254
+ // The on-demand resting hook rests without polling, so when the box warms the
255
+ // cold capability doc never refreshes on its own — Terminal/Desktop would stay
256
+ // hidden. Watch the provision edge and renegotiate when it settles so the
257
+ // freshly-warm box's surfaces fill in.
258
+ const provisioning = sandboxProvisionInFlight(events);
259
+ const renegotiate = caps.renegotiate;
260
+ const provisioningRef = useRef({ sessionId, provisioning });
261
+ useEffect(() => {
262
+ const previous = provisioningRef.current;
263
+ if (previous.sessionId === sessionId && previous.provisioning && !provisioning) {
264
+ renegotiate();
265
+ }
266
+ provisioningRef.current = { sessionId, provisioning };
267
+ }, [sessionId, provisioning, renegotiate]);
268
+
269
+ // The cold-paint data source: the latest turn-end capture, fetched with a single
270
+ // api round-trip on mount (no machine). Feeds the Files tree + the Changes/Git
271
+ // diff when the box is not warm; a warm box always wins (live path unchanged).
272
+ const captureState = useWorkspaceCapture(sessionId, { events });
273
+ const captureAvailable = captureState.available;
274
+ const notifiedCaptureDegradedReason = useRef<{
275
+ sessionId: string;
276
+ reason: string | null;
277
+ }>({
278
+ sessionId,
279
+ reason: null,
280
+ });
281
+ if (notifiedCaptureDegradedReason.current.sessionId !== sessionId) {
282
+ notifiedCaptureDegradedReason.current = { sessionId, reason: null };
283
+ }
284
+ useEffect(() => {
285
+ const reason = captureState.degradedReason;
286
+ if (!reason || notifiedCaptureDegradedReason.current.reason === reason) return;
287
+ notifiedCaptureDegradedReason.current = { sessionId, reason };
288
+ onNotify?.({ kind: "error", message: captureDegradedMessage(reason) });
289
+ }, [sessionId, captureState.degradedReason, onNotify]);
290
+
291
+ const files = useSandboxFiles(sessionId, {
292
+ events,
293
+ enabled: fileSystemOn || captureAvailable,
294
+ liveness,
295
+ capture: captureState.capture,
296
+ // A reverted optimistic mutation (e.g. a 409 rename collision) surfaces as a
297
+ // host notification — the tree silently rolls back, the user sees why.
298
+ onMutationError: (error, op) =>
299
+ onNotify?.({
300
+ kind: "error",
301
+ message: `Could not ${op}: ${error.message}`,
302
+ }),
303
+ });
304
+ const git = useSandboxGit(sessionId, {
305
+ events,
306
+ enabled: gitOn || captureAvailable,
307
+ liveness,
308
+ capture: captureState.capture,
309
+ });
310
+ const stagedGit = useSandboxGit(sessionId, {
311
+ events,
312
+ enabled: gitOn,
313
+ staged: true,
314
+ liveness,
315
+ capture: captureState.capture,
316
+ });
317
+
318
+ // Token-derived xterm theme; re-derive on a `data-og-theme` flip. Generic — it
319
+ // belongs in the package (an embedder's theme toggle drives it too).
320
+ const [xtermTheme, setXtermTheme] = useState<XtermTheme | undefined>(undefined);
321
+ useEffect(() => {
322
+ if (typeof document === "undefined") return;
323
+ const derive = () => setXtermTheme(xtermThemeFromTokens());
324
+ derive();
325
+ const observer = new MutationObserver(derive);
326
+ observer.observe(document.documentElement, {
327
+ attributes: true,
328
+ attributeFilter: ["data-og-theme", "class"],
329
+ });
330
+ return () => observer.disconnect();
331
+ }, []);
332
+
333
+ async function acknowledgeAndWatch() {
334
+ try {
335
+ const shared = capabilities?.DesktopStream.shared ?? false;
336
+ await client.acknowledgeStream(workspaceId, sessionId, {
337
+ acknowledgeUnredacted: true,
338
+ acknowledgeShared: shared,
339
+ });
340
+ requestWarmIntent("watchDesktop");
341
+ caps.renegotiate();
342
+ } catch (error) {
343
+ onNotify?.({
344
+ kind: "error",
345
+ message: `Could not start the desktop stream: ${error instanceof Error ? error.message : String(error)}`,
346
+ });
347
+ }
348
+ }
349
+
350
+ // Re-warm WITHOUT re-acknowledging: the consent was already recorded, only the
351
+ // box drained back to cold. Idempotent — the viewer auto-warm de-dupes.
352
+ function rewarmDesktop() {
353
+ if (!watchDesktop) requestWarmIntent("watchDesktop");
354
+ caps.renegotiate();
355
+ }
356
+
357
+ const dirtyCount = git.diff.length;
358
+
359
+ // The one truthful machine indicator, derived from the live capability/liveness
360
+ // surface + the active machine's connection state + the latest capture time.
361
+ const chip = useMachineChip({
362
+ liveness,
363
+ capabilitiesState: caps.state,
364
+ activeMachineState: activeMachine?.state ?? null,
365
+ activeIsSelfhosted: activeMachine?.kind === "selfhosted",
366
+ wantsWarm: warmTerminal || watchDesktop || warmEdit,
367
+ capturedAt: captureState.capturedAt,
368
+ });
369
+
370
+ // The pre-paint default tab, decided from the workbench's OWN capture fetch — no
371
+ // embedder events-at-mount contract (Refinement 2). A host `initialTab` wins
372
+ // immediately; otherwise the default stays null until the capture GET first
373
+ // resolves, then latches Changes (changes exist) or Files, ONCE — live data can
374
+ // never switch it afterward. Committing at first-resolve — before the tab body's
375
+ // first CONTENT paint (both bodies show a connecting/loading state until the
376
+ // capture lands) — means the first real content is the correct tab, no switch. A
377
+ // pure embedder that never preloads events now gets the right default for free.
378
+ const defaultTabRef = useRef<{ sessionId: string; value: string | null }>({
379
+ sessionId,
380
+ value: null,
381
+ });
382
+ if (defaultTabRef.current.sessionId !== sessionId) {
383
+ defaultTabRef.current = { sessionId, value: null };
384
+ }
385
+ if (defaultTabRef.current.value === null) {
386
+ if (initialTab) {
387
+ defaultTabRef.current.value = initialTab;
388
+ } else if (captureState.fileCount !== null) {
389
+ defaultTabRef.current.value =
390
+ captureState.fileCount > 0 ? WORKBENCH_TAB_CHANGES : WORKBENCH_TAB_FILES;
391
+ }
392
+ }
393
+ // null only during the brief pre-first-resolve window (no host override yet).
394
+ const defaultTab = defaultTabRef.current.value;
395
+
396
+ const tabs = useMemo(() => {
397
+ const list: WorkspaceTab[] = [];
398
+
399
+ // Changes — always present (capture-backed, works cold/offline). The default
400
+ // primary surface. M5 replaces this body with the windowed diff renderer; the
401
+ // seam is the `<ChangesTabBody>` element (this is the minimal placeholder).
402
+ list.push({
403
+ id: WORKBENCH_TAB_CHANGES,
404
+ label: "Changes",
405
+ badge: dirtyCount > 0 ? <DirtyBadge count={dirtyCount} /> : undefined,
406
+ content: (
407
+ <ChangesTabBody
408
+ git={git}
409
+ captureAvailable={captureAvailable}
410
+ captureRevision={captureState.revision}
411
+ capabilitiesState={caps.state}
412
+ capabilitiesError={caps.error}
413
+ onRetry={caps.renegotiate}
414
+ />
415
+ ),
416
+ });
417
+
418
+ // Files — always present (capture-backed cold tree; live warm). M5 virtualizes.
419
+ list.push({
420
+ id: WORKBENCH_TAB_FILES,
421
+ label: "Files",
422
+ content: (
423
+ <SandboxFiles
424
+ files={files}
425
+ git={git}
426
+ stagedGit={stagedGit}
427
+ fileSystemAvailable={fileSystemOn || captureAvailable}
428
+ editable={filesEditable}
429
+ // The first keystroke in the editor is the wake-on-edit INTENT: warm the
430
+ // box (even cold) so the write lands fast. Opening/reading files does not.
431
+ onEditIntent={() => requestWarmIntent("warmEdit")}
432
+ className="h-full"
433
+ />
434
+ ),
435
+ });
436
+
437
+ // Terminal — capability-gated (appears after negotiation; never the default).
438
+ if (terminalOn) {
439
+ list.push({
440
+ id: "terminal",
441
+ label: "Terminal",
442
+ content: (
443
+ <div className="h-full bg-og-bg p-1">
444
+ <SandboxTerminal
445
+ result={terminal}
446
+ terminalCapability={capabilities?.Terminal ?? null}
447
+ onActivate={() => requestWarmIntent("warmTerminal")}
448
+ showHeader
449
+ shell={capabilities?.Terminal.shell ?? undefined}
450
+ liveness={liveness}
451
+ {...(xtermTheme ? { theme: xtermTheme } : {})}
452
+ />
453
+ </div>
454
+ ),
455
+ });
456
+ }
457
+
458
+ // Desktop — capability-gated + consent-gated.
459
+ if (desktopAdvertised) {
460
+ list.push({
461
+ id: "desktop",
462
+ label: "Desktop",
463
+ badge: watchDesktop ? (
464
+ <span className="rounded-og-xs bg-og-status-running/20 px-1 text-2xs text-og-status-running">
465
+ Live
466
+ </span>
467
+ ) : undefined,
468
+ content: (
469
+ <DesktopViewer
470
+ capability={capabilities?.DesktopStream ?? null}
471
+ viewerCapReached={caps.viewerCapReached}
472
+ watching={watchDesktop}
473
+ onAcknowledge={() => void acknowledgeAndWatch()}
474
+ onWarm={rewarmDesktop}
475
+ className="h-full"
476
+ />
477
+ ),
478
+ });
479
+ }
480
+
481
+ return list;
482
+ // eslint-disable-next-line react-hooks/exhaustive-deps
483
+ }, [
484
+ fileSystemOn,
485
+ terminalOn,
486
+ desktopAdvertised,
487
+ captureAvailable,
488
+ dirtyCount,
489
+ watchDesktop,
490
+ warmTerminal,
491
+ files,
492
+ git,
493
+ stagedGit,
494
+ terminal,
495
+ xtermTheme,
496
+ capabilities,
497
+ caps.state,
498
+ caps.error,
499
+ caps.viewerCapReached,
500
+ requestWarmIntent,
501
+ ]);
502
+
503
+ return {
504
+ tabs,
505
+ defaultTab,
506
+ machine: {
507
+ chip,
508
+ activeMachine,
509
+ loading: machines.loading,
510
+ error: machines.error,
511
+ refresh: machines.refresh,
512
+ },
513
+ };
514
+ }
515
+
516
+ export type SandboxWorkspaceProps = ClientOverride & {
517
+ sessionId: string;
518
+ /** Live event log (usually `useSessionEvents().events`). */
519
+ events: SessionEvent[];
520
+ /** The chat / primary pane shown beside the dock. */
521
+ primary: ReactNode;
522
+ /** Host tabs injected BEFORE the workbench tabs (e.g. a "Run" landing tab). */
523
+ leadingTabs?: WorkspaceTab[] | undefined;
524
+ /** Host tabs injected AFTER the workbench tabs (e.g. a "Debug" tab). */
525
+ trailingTabs?: WorkspaceTab[] | undefined;
526
+ /** Override the pre-paint default tab (e.g. a host landing tab id). When
527
+ * omitted the workbench decides Changes-vs-Files from local capture stats. */
528
+ initialTab?: string | undefined;
529
+ /** Host-routed notifications (no toast dependency in the package). */
530
+ onNotify?: ((notification: WorkspaceNotification) => void) | undefined;
531
+ /** Controlled collapsed state for hosts with their own dock toggle. */
532
+ collapsed?: boolean | undefined;
533
+ onCollapsedChange?: ((collapsed: boolean) => void) | undefined;
534
+ /** Host navigation shown only in the phone workspace overlay header. */
535
+ mobileLeadingControl?: ReactNode | undefined;
536
+ autoSaveId?: string | undefined;
537
+ defaultSize?: number | undefined;
538
+ minSize?: number | undefined;
539
+ maxSize?: number | undefined;
540
+ className?: string | undefined;
541
+ };
542
+
543
+ /**
544
+ * The whole session workspace, ready to mount: `<SandboxWorkspace>` assembles the
545
+ * capability-gated tabs, pins the machine-state chip in the dock header, and
546
+ * renders the resizable `<WorkspaceDock>`. Wrap the tree in `<OpenGeniProvider>`
547
+ * (client + workspaceId) and import `@opengeni/react/styles.css`; that is the
548
+ * entire integration (see `docs/embedding-workbench.md`).
549
+ */
550
+ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
551
+ const {
552
+ sessionId,
553
+ events,
554
+ primary,
555
+ leadingTabs,
556
+ trailingTabs,
557
+ initialTab,
558
+ onNotify,
559
+ collapsed,
560
+ onCollapsedChange,
561
+ mobileLeadingControl,
562
+ autoSaveId,
563
+ defaultSize,
564
+ minSize,
565
+ maxSize,
566
+ className,
567
+ } = props;
568
+
569
+ const {
570
+ tabs: workbenchTabs,
571
+ machine,
572
+ defaultTab,
573
+ } = useSandboxWorkspaceTabs({
574
+ ...(props.client ? { client: props.client } : {}),
575
+ ...(props.workspaceId ? { workspaceId: props.workspaceId } : {}),
576
+ sessionId,
577
+ events,
578
+ ...(initialTab ? { initialTab } : {}),
579
+ ...(onNotify ? { onNotify } : {}),
580
+ });
581
+
582
+ // A user's tab click wins forever; before that we follow the capture-driven
583
+ // default. While it is still resolving (null, pure-embedder pre-first-resolve)
584
+ // we pass no controlled tab, so the dock renders its own first-tab fallback
585
+ // (Changes) — whose body is a connecting/loading state until the capture lands,
586
+ // so committing the real default at first-resolve produces no CONTENT switch.
587
+ const tabs: WorkspaceTab[] = [...(leadingTabs ?? []), ...workbenchTabs, ...(trailingTabs ?? [])];
588
+ const [storedSelection, setStoredSelection] = useState<{
589
+ sessionId: string;
590
+ tab: string;
591
+ } | null>(null);
592
+ const selectedTab = storedSelection?.sessionId === sessionId ? storedSelection.tab : null;
593
+ const activeTab = selectedTab ?? defaultTab ?? tabs[0]?.id;
594
+ const selectTab = useCallback(
595
+ (tab: string) => setStoredSelection({ sessionId, tab }),
596
+ [sessionId],
597
+ );
598
+
599
+ return (
600
+ <WorkspaceDock
601
+ primary={primary}
602
+ tabs={tabs}
603
+ {...(activeTab !== undefined ? { activeTab } : {})}
604
+ onActiveTabChange={selectTab}
605
+ headerAccessory={
606
+ <MachineStateChip
607
+ chip={machine.chip}
608
+ activeMachine={machine.activeMachine}
609
+ error={machine.error}
610
+ onRetry={() => void machine.refresh()}
611
+ />
612
+ }
613
+ {...(mobileLeadingControl !== undefined ? { mobileLeadingControl } : {})}
614
+ {...(collapsed !== undefined ? { collapsed } : {})}
615
+ {...(onCollapsedChange ? { onCollapsedChange } : {})}
616
+ {...(autoSaveId !== undefined ? { autoSaveId } : {})}
617
+ {...(defaultSize !== undefined ? { defaultSize } : {})}
618
+ {...(minSize !== undefined ? { minSize } : {})}
619
+ {...(maxSize !== undefined ? { maxSize } : {})}
620
+ {...(className !== undefined ? { className } : {})}
621
+ />
622
+ );
623
+ }
624
+
625
+ // Re-export the dock so an embedder can grab the shell type off one import.
626
+ export type { WorkspaceDockProps };
627
+
628
+ function DirtyBadge({ count }: { count: number }) {
629
+ return (
630
+ <span className="rounded-og-xs bg-og-accent-soft px-1 text-2xs text-og-fg-muted">{count}</span>
631
+ );
632
+ }
633
+
634
+ /** A machine-kind glyph for the chip popover. */
635
+ function MachineKindIcon({
636
+ kind,
637
+ className,
638
+ }: {
639
+ kind: MachineView["kind"] | undefined;
640
+ className?: string;
641
+ }) {
642
+ const Icon = kind === "selfhosted" ? LaptopIcon : CpuIcon;
643
+ return <Icon className={cn("size-3.5 shrink-0 text-og-fg-subtle", className)} aria-hidden />;
644
+ }
645
+
646
+ function chipDotClass(state: MachineChip["state"]): string {
647
+ if (state === "live") return "bg-og-status-running";
648
+ if (state === "waking") return "bg-og-status-idle animate-pulse";
649
+ return "bg-og-fg-subtle";
650
+ }
651
+
652
+ /**
653
+ * The dock-header machine chip: the one truthful live/waking/offline indicator,
654
+ * with a popover carrying the machine identity, connection state, the "shown as
655
+ * of <time>" staleness note, the shared-session disclosure, and a retry when the
656
+ * fleet failed to resolve (the old per-surface machine bar + Sandbox info tab,
657
+ * folded into one header affordance — dossier §10.6 recommendation).
658
+ */
659
+ function MachineStateChip({
660
+ chip,
661
+ activeMachine,
662
+ error,
663
+ onRetry,
664
+ }: {
665
+ chip: MachineChip;
666
+ activeMachine: MachineView | null;
667
+ error: Error | null;
668
+ onRetry: () => void;
669
+ }) {
670
+ return (
671
+ <Popover.Root>
672
+ <Popover.Trigger asChild>
673
+ <button
674
+ type="button"
675
+ aria-label={`Machine: ${chip.label}`}
676
+ className="inline-flex min-h-7 items-center gap-1.5 rounded-og-sm px-2 py-1 text-og-xs font-medium text-og-fg-muted transition-colors hover:bg-og-surface-2 hover:text-og-fg max-[1023px]:min-h-11 pointer-coarse:min-h-11"
677
+ >
678
+ <span
679
+ className={cn("size-1.5 shrink-0 rounded-full", chipDotClass(chip.state))}
680
+ aria-hidden
681
+ />
682
+ <span className="max-w-[11rem] truncate">{chip.label}</span>
683
+ </button>
684
+ </Popover.Trigger>
685
+ <Popover.Portal forceMount>
686
+ <Popover.Content
687
+ forceMount
688
+ align="end"
689
+ sideOffset={6}
690
+ className="z-50 w-64 rounded-og-md border border-og-border bg-og-surface-1 p-3 text-og-sm text-og-fg shadow-lg outline-none data-[state=closed]:hidden"
691
+ >
692
+ <div className="flex min-w-0 items-center gap-1.5">
693
+ <MachineKindIcon kind={activeMachine?.kind} />
694
+ <span className="min-w-0 truncate font-medium">{activeMachine?.name ?? "Sandbox"}</span>
695
+ </div>
696
+ <div className="mt-2.5 flex items-center justify-between gap-2">
697
+ <span className="text-og-xs text-og-fg-subtle">Connection</span>
698
+ {activeMachine ? (
699
+ <ConnectionStatusPill
700
+ status={connectionStatusForState(activeMachine.state)}
701
+ size="sm"
702
+ />
703
+ ) : (
704
+ <span className="text-og-xs text-og-fg-muted">{chip.label}</span>
705
+ )}
706
+ </div>
707
+ {chip.asOf ? (
708
+ <p className="mt-2.5 text-og-xs leading-4 text-og-fg-subtle">
709
+ Workspace shown as of {formatAsOf(chip.asOf, Date.now())} — the machine is not live.
710
+ </p>
711
+ ) : null}
712
+ {activeMachine && activeMachine.sharedSessionCount > 1 ? (
713
+ <div className="mt-2.5">
714
+ <SharedMachineDisclosure
715
+ sharedSessionCount={activeMachine.sharedSessionCount}
716
+ density="full"
717
+ />
718
+ </div>
719
+ ) : null}
720
+ {error ? (
721
+ <div className="mt-2.5 space-y-1.5 border-t border-og-border pt-2.5">
722
+ <p className="text-og-xs leading-4 text-og-status-danger">
723
+ Couldn't reach the sandbox for this session.
724
+ </p>
725
+ <DockActionButton onClick={onRetry}>
726
+ <RefreshCwIcon className="size-3" />
727
+ Retry
728
+ </DockActionButton>
729
+ </div>
730
+ ) : null}
731
+ </Popover.Content>
732
+ </Popover.Portal>
733
+ </Popover.Root>
734
+ );
735
+ }
736
+
737
+ /** A minimal token-styled action button (the package has no app Button import). */
738
+ function DockActionButton({ onClick, children }: { onClick: () => void; children: ReactNode }) {
739
+ return (
740
+ <button
741
+ type="button"
742
+ onClick={onClick}
743
+ className="inline-flex items-center gap-1.5 rounded-og-sm border border-og-border px-2 py-1 text-og-xs font-medium text-og-fg-muted transition-colors hover:border-og-border-strong hover:text-og-fg pointer-coarse:min-h-9"
744
+ >
745
+ {children}
746
+ </button>
747
+ );
748
+ }
749
+
750
+ /**
751
+ * The Changes tab body: the real PR-review surface (`WorkbenchChanges` — file
752
+ * rail + windowed Pierre diff pane) when there are changes, wrapped in the honest
753
+ * connecting/offline/empty states so the default tab is never a blank surface.
754
+ * The dock frame is untouched; this is the M5 seam.
755
+ */
756
+ function ChangesTabBody({
757
+ git,
758
+ captureAvailable,
759
+ captureRevision,
760
+ capabilitiesState,
761
+ capabilitiesError,
762
+ onRetry,
763
+ }: {
764
+ git: UseSandboxGitResult;
765
+ captureAvailable: boolean;
766
+ captureRevision: number | null;
767
+ capabilitiesState: string;
768
+ capabilitiesError: Error | null;
769
+ onRetry: () => void;
770
+ }) {
771
+ const diff = git.diff;
772
+
773
+ if (diff.length > 0) {
774
+ return (
775
+ <WorkbenchChanges
776
+ diff={diff}
777
+ source={git.source}
778
+ capturedAt={git.capturedAt}
779
+ captureRevision={captureRevision}
780
+ />
781
+ );
782
+ }
783
+
784
+ if (capabilitiesError && !captureAvailable) {
785
+ return (
786
+ <CenteredState>
787
+ <p className="text-og-sm font-medium text-og-fg">Sandbox unavailable</p>
788
+ <p className="text-og-sm leading-5 text-og-fg-muted">
789
+ {capabilitiesError.message || "Couldn't reach the sandbox for this session."}
790
+ </p>
791
+ <DockActionButton onClick={onRetry}>
792
+ <RefreshCwIcon className="size-3" />
793
+ Retry
794
+ </DockActionButton>
795
+ </CenteredState>
796
+ );
797
+ }
798
+
799
+ if ((capabilitiesState === "negotiating" || capabilitiesState === "cold") && !captureAvailable) {
800
+ return (
801
+ <CenteredState>
802
+ <p className="text-og-sm text-og-fg-muted">Connecting sandbox…</p>
803
+ </CenteredState>
804
+ );
805
+ }
806
+
807
+ return (
808
+ <CenteredState>
809
+ <p className="text-og-sm font-medium text-og-fg">No changes yet</p>
810
+ <p className="text-og-sm leading-5 text-og-fg-subtle">
811
+ File edits from this session's turns show up here.
812
+ </p>
813
+ </CenteredState>
814
+ );
815
+ }
816
+
817
+ function CenteredState({ children }: { children: ReactNode }) {
818
+ return (
819
+ <div className="grid h-full place-items-center p-6 text-center">
820
+ <div className="flex max-w-sm flex-col items-center gap-2.5">{children}</div>
821
+ </div>
822
+ );
823
+ }