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