@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.
- package/README.md +20 -14
- package/dist/chunk-TOJR776I.js +2280 -0
- package/dist/chunk-TOJR776I.js.map +1 -0
- package/dist/index.d.ts +1040 -363
- package/dist/index.js +10938 -6281
- package/dist/index.js.map +1 -1
- package/dist/{machines-BeZ3bD3t.d.ts → machines-BpdwuQcD.d.ts} +134 -14
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +23 -1
- package/package.json +20 -12
- package/src/approvals.ts +16 -4
- package/src/client.ts +41 -5
- package/src/commands/index.ts +1 -7
- package/src/commands/registry.ts +46 -12
- package/src/components/chat-composer.tsx +417 -69
- package/src/components/code-editor.tsx +28 -36
- package/src/components/command-palette.tsx +26 -5
- package/src/components/desktop-viewer.tsx +29 -16
- package/src/components/diff-view.tsx +27 -189
- package/src/components/enrollment-consent.tsx +28 -10
- package/src/components/enrollment-device-flow.tsx +8 -5
- package/src/components/file-browser.tsx +190 -56
- package/src/components/fleet-tile.tsx +13 -4
- package/src/components/machine-card.tsx +93 -15
- package/src/components/machine-dock-bar.tsx +20 -8
- package/src/components/machine-health-pill.tsx +68 -0
- package/src/components/machine-metrics.tsx +28 -19
- package/src/components/machine-status-pill.tsx +26 -5
- package/src/components/machines/health.ts +146 -0
- package/src/components/machines/machine-detail.tsx +220 -0
- package/src/components/machines/metric-history-chart.tsx +298 -0
- package/src/components/machines/metric-sparkline.tsx +76 -0
- package/src/components/machines/series.ts +113 -0
- package/src/components/machines-dashboard.tsx +21 -3
- package/src/components/markdown.tsx +39 -9
- package/src/components/message-timeline.tsx +633 -109
- package/src/components/pierre-diff.tsx +83 -15
- package/src/components/pierre-file.tsx +10 -9
- package/src/components/queue-surface.tsx +578 -0
- package/src/components/sandbox-files.tsx +193 -225
- package/src/components/sandbox-terminal.tsx +256 -88
- package/src/components/sandbox-workspace.tsx +957 -0
- package/src/components/session-status.tsx +22 -2
- package/src/components/workbench-changes.tsx +585 -0
- package/src/components/workspace-dock.tsx +213 -59
- package/src/hooks/internal.ts +5 -2
- package/src/hooks/use-available-models.ts +7 -2
- package/src/hooks/use-billing-usage.ts +4 -1
- package/src/hooks/use-codex-accounts.ts +74 -44
- package/src/hooks/use-composer.ts +414 -63
- package/src/hooks/use-environments.ts +92 -47
- package/src/hooks/use-file-attachments.ts +101 -55
- package/src/hooks/use-goal.ts +52 -16
- package/src/hooks/use-machine-chip.ts +134 -0
- package/src/hooks/use-machines.ts +34 -16
- package/src/hooks/use-packs.ts +24 -19
- package/src/hooks/use-relay-frame-stream.ts +5 -2
- package/src/hooks/use-rigs.ts +335 -0
- package/src/hooks/use-sandbox-files.ts +213 -39
- package/src/hooks/use-sandbox-git.ts +188 -11
- package/src/hooks/use-sandbox-terminal.ts +18 -14
- package/src/hooks/use-scheduled-tasks.ts +10 -2
- package/src/hooks/use-session-capabilities.ts +77 -20
- package/src/hooks/use-session-control.ts +50 -23
- package/src/hooks/use-session-events.ts +50 -21
- package/src/hooks/use-session-lineage.ts +128 -0
- package/src/hooks/use-session.ts +53 -30
- package/src/hooks/use-slash-commands.ts +6 -4
- package/src/hooks/use-turn-queue.ts +202 -147
- package/src/hooks/use-windowed-sections.ts +204 -0
- package/src/hooks/use-workspace-capture.ts +233 -0
- package/src/hooks/use-workspace-edit.ts +231 -0
- package/src/hooks/use-workspace-sessions.ts +48 -5
- package/src/hooks/use-workspaces.ts +18 -15
- package/src/index.ts +133 -23
- package/src/lib/format.ts +3 -3
- package/src/lib/xterm-renderer.ts +65 -0
- package/src/lib/xterm-theme.ts +224 -18
- package/src/machines.ts +29 -3
- package/src/provider.tsx +195 -6
- package/src/timeline/activity-rail.tsx +213 -54
- package/src/timeline/disclosure-context.tsx +12 -2
- package/src/timeline/index.ts +21 -2
- package/src/timeline/parsers.ts +87 -16
- package/src/timeline/projection.ts +412 -87
- package/src/timeline/shared.tsx +111 -24
- package/src/timeline/tool-diff.tsx +24 -20
- package/src/timeline/tool-renderers.tsx +98 -21
- package/src/timeline/turn-summary.tsx +76 -23
- package/src/timeline/types.ts +96 -12
- package/styles/index.css +22 -0
- package/styles/tokens.css +2 -2
- package/dist/chunk-5TIXRCSI.js +0 -1291
- 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
|
+
}
|