@opengeni/react 0.13.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 +19 -13
- package/dist/chunk-TOJR776I.js +2280 -0
- package/dist/chunk-TOJR776I.js.map +1 -0
- package/dist/index.d.ts +314 -255
- package/dist/index.js +5862 -4404
- package/dist/index.js.map +1 -1
- package/dist/{machines-CnlMb7E-.d.ts → machines-BpdwuQcD.d.ts} +130 -10
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +23 -1
- package/package.json +5 -2
- package/src/client.ts +10 -1
- package/src/components/chat-composer.tsx +309 -57
- package/src/components/machine-card.tsx +81 -15
- package/src/components/machine-health-pill.tsx +68 -0
- package/src/components/machine-metrics.tsx +10 -24
- 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 +13 -1
- package/src/components/queue-surface.tsx +578 -0
- package/src/components/sandbox-files.tsx +94 -9
- package/src/components/sandbox-workspace.tsx +186 -52
- package/src/components/session-status.tsx +0 -6
- package/src/components/workbench-changes.tsx +64 -20
- package/src/components/workspace-dock.tsx +146 -55
- package/src/hooks/use-composer.ts +369 -39
- package/src/hooks/use-session-control.ts +6 -7
- package/src/hooks/use-session-events.ts +3 -2
- package/src/hooks/use-session-lineage.ts +15 -6
- package/src/hooks/use-session.ts +10 -2
- package/src/hooks/use-turn-queue.ts +175 -47
- package/src/index.ts +13 -7
- package/src/machines.ts +16 -0
- package/src/provider.tsx +192 -5
- package/src/timeline/parsers.ts +43 -6
- package/src/timeline/projection.ts +24 -2
- package/styles/index.css +22 -0
- package/dist/chunk-NFYVQWIB.js +0 -1377
- package/dist/chunk-NFYVQWIB.js.map +0 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FsReadResponse } from "@opengeni/sdk";
|
|
2
|
+
import { FileCode2Icon, FileWarningIcon, LoaderCircleIcon } from "lucide-react";
|
|
2
3
|
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
3
4
|
import { cn } from "../lib/cn";
|
|
4
5
|
import { useThemeType } from "../lib/use-theme-type";
|
|
@@ -27,6 +28,14 @@ export type SandboxFilesProps = {
|
|
|
27
28
|
* dock warms the box on this so the save lands fast; opening/reading never fires
|
|
28
29
|
* it. Browsing the tree/diff must not warm a box. */
|
|
29
30
|
onEditIntent?: (() => void) | undefined;
|
|
31
|
+
/** A guarded diff path routed here by the parent workspace. */
|
|
32
|
+
requestedPath?: string | undefined;
|
|
33
|
+
/** Identity for one guarded-file request. Increment this when the same path is
|
|
34
|
+
* deliberately requested again; it also lets a pending request be consumed
|
|
35
|
+
* without overriding later manual tree navigation. Defaults to the path. */
|
|
36
|
+
requestedPathRequestId?: string | number | undefined;
|
|
37
|
+
/** False while the parent is waking a cold sandbox for `requestedPath`. */
|
|
38
|
+
requestedPathReady?: boolean | undefined;
|
|
30
39
|
themeType?: "dark" | "light" | undefined;
|
|
31
40
|
className?: string | undefined;
|
|
32
41
|
};
|
|
@@ -45,6 +54,9 @@ export function SandboxFiles({
|
|
|
45
54
|
usePierre = true,
|
|
46
55
|
editable = true,
|
|
47
56
|
onEditIntent,
|
|
57
|
+
requestedPath,
|
|
58
|
+
requestedPathRequestId,
|
|
59
|
+
requestedPathReady = true,
|
|
48
60
|
themeType,
|
|
49
61
|
className,
|
|
50
62
|
}: SandboxFilesProps) {
|
|
@@ -52,6 +64,28 @@ export function SandboxFiles({
|
|
|
52
64
|
// View vs Edit for the selected file. Resets to View on every new selection so
|
|
53
65
|
// opening a file never lands you in a stale dirty editor for a different path.
|
|
54
66
|
const [editMode, setEditMode] = useState(false);
|
|
67
|
+
const pendingRequestRef = useRef<string | number | null>(null);
|
|
68
|
+
const handledRequestRef = useRef<string | number | null>(null);
|
|
69
|
+
const requestKey = requestedPath ? (requestedPathRequestId ?? requestedPath) : null;
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!requestedPath || requestKey === null) {
|
|
73
|
+
pendingRequestRef.current = null;
|
|
74
|
+
handledRequestRef.current = null;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (handledRequestRef.current === requestKey) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (!requestedPathReady) {
|
|
81
|
+
pendingRequestRef.current = requestKey;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
handledRequestRef.current = requestKey;
|
|
85
|
+
pendingRequestRef.current = null;
|
|
86
|
+
setSelected(requestedPath);
|
|
87
|
+
setEditMode(false);
|
|
88
|
+
}, [requestKey, requestedPath, requestedPathReady]);
|
|
55
89
|
|
|
56
90
|
// Side-by-side (tree left, viewer right) once the surface is wide enough;
|
|
57
91
|
// stacked (tree over viewer) on a narrow dock. Tracked off the container so it
|
|
@@ -80,8 +114,14 @@ export function SandboxFiles({
|
|
|
80
114
|
const fileView = useFileView(viewPath, files.readFile);
|
|
81
115
|
|
|
82
116
|
// Selecting a (different) file always returns to View — never drop the user into
|
|
83
|
-
// an editor whose buffer belongs to the previously-selected path.
|
|
117
|
+
// an editor whose buffer belongs to the previously-selected path. Manual
|
|
118
|
+
// navigation also consumes a pending guarded-file request: a late cold→warm
|
|
119
|
+
// transition must never pull the user away from the file they chose meanwhile.
|
|
84
120
|
const selectFile = useCallback((path: string) => {
|
|
121
|
+
if (pendingRequestRef.current !== null) {
|
|
122
|
+
handledRequestRef.current = pendingRequestRef.current;
|
|
123
|
+
pendingRequestRef.current = null;
|
|
124
|
+
}
|
|
85
125
|
setSelected(path);
|
|
86
126
|
setEditMode(false);
|
|
87
127
|
}, []);
|
|
@@ -101,7 +141,15 @@ export function SandboxFiles({
|
|
|
101
141
|
const showEditor = canEdit && editMode;
|
|
102
142
|
|
|
103
143
|
if (!fileSystemAvailable) {
|
|
104
|
-
return
|
|
144
|
+
return (
|
|
145
|
+
<Notice
|
|
146
|
+
className={className}
|
|
147
|
+
icon={<FileWarningIcon className="size-5" aria-hidden />}
|
|
148
|
+
title="Files unavailable"
|
|
149
|
+
>
|
|
150
|
+
This sandbox does not expose a file system.
|
|
151
|
+
</Notice>
|
|
152
|
+
);
|
|
105
153
|
}
|
|
106
154
|
|
|
107
155
|
return (
|
|
@@ -138,7 +186,10 @@ export function SandboxFiles({
|
|
|
138
186
|
)}
|
|
139
187
|
>
|
|
140
188
|
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-og-border bg-og-surface-1 px-2 py-1">
|
|
141
|
-
<span
|
|
189
|
+
<span
|
|
190
|
+
data-opengeni-selected-file
|
|
191
|
+
className="min-w-0 truncate font-og-mono text-og-xs text-og-fg-muted"
|
|
192
|
+
>
|
|
142
193
|
{selected ?? "No file selected"}
|
|
143
194
|
</span>
|
|
144
195
|
{/* View/Edit toggle — only for a real, fully-loaded text file the editor
|
|
@@ -206,9 +257,23 @@ export function SandboxFiles({
|
|
|
206
257
|
) : (
|
|
207
258
|
<Notice>Loading {viewPath}…</Notice>
|
|
208
259
|
)
|
|
260
|
+
) : // Nothing selected — the tree shows the whole workspace; pick a file.
|
|
261
|
+
requestedPath && !requestedPathReady ? (
|
|
262
|
+
<Notice
|
|
263
|
+
icon={
|
|
264
|
+
<LoaderCircleIcon
|
|
265
|
+
className="size-5 animate-spin motion-reduce:animate-none"
|
|
266
|
+
aria-hidden
|
|
267
|
+
/>
|
|
268
|
+
}
|
|
269
|
+
title="Waking sandbox"
|
|
270
|
+
>
|
|
271
|
+
Opening {requestedPath} when the live workspace is ready…
|
|
272
|
+
</Notice>
|
|
209
273
|
) : (
|
|
210
|
-
|
|
211
|
-
|
|
274
|
+
<Notice icon={<FileCode2Icon className="size-5" aria-hidden />} title="Choose a file">
|
|
275
|
+
Select a file in the tree to preview it.
|
|
276
|
+
</Notice>
|
|
212
277
|
)}
|
|
213
278
|
</div>
|
|
214
279
|
</div>
|
|
@@ -238,7 +303,9 @@ function GitHeader({ git, dirtyCount }: { git: UseSandboxGitResult; dirtyCount:
|
|
|
238
303
|
</span>
|
|
239
304
|
)}
|
|
240
305
|
{dirty && (
|
|
241
|
-
<span className="ml-auto shrink-0 text-og-xs text-og-fg-subtle">
|
|
306
|
+
<span data-contrast-audited className="ml-auto shrink-0 text-og-xs text-og-fg-subtle">
|
|
307
|
+
{dirtyCount} changed
|
|
308
|
+
</span>
|
|
242
309
|
)}
|
|
243
310
|
</div>
|
|
244
311
|
);
|
|
@@ -261,7 +328,7 @@ function Segmented({
|
|
|
261
328
|
type="button"
|
|
262
329
|
onClick={() => onChange(opt.value)}
|
|
263
330
|
className={cn(
|
|
264
|
-
"min-h-7 rounded-og-xs px-1.5 py-0.5 text-og-xs max-[1023px]:min-h-11 pointer-coarse:min-h-11",
|
|
331
|
+
"min-h-7 rounded-og-xs px-1.5 py-0.5 text-og-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent max-[1023px]:min-h-11 pointer-coarse:min-h-11",
|
|
265
332
|
opt.value === value
|
|
266
333
|
? "bg-og-accent-soft text-og-fg"
|
|
267
334
|
: "text-og-fg-subtle hover:text-og-fg",
|
|
@@ -375,7 +442,17 @@ function decodeBase64Utf8(b64: string): string {
|
|
|
375
442
|
return b64;
|
|
376
443
|
}
|
|
377
444
|
|
|
378
|
-
function Notice({
|
|
445
|
+
function Notice({
|
|
446
|
+
children,
|
|
447
|
+
className,
|
|
448
|
+
icon,
|
|
449
|
+
title,
|
|
450
|
+
}: {
|
|
451
|
+
children: ReactNode;
|
|
452
|
+
className?: string | undefined;
|
|
453
|
+
icon?: ReactNode | undefined;
|
|
454
|
+
title?: string | undefined;
|
|
455
|
+
}) {
|
|
379
456
|
return (
|
|
380
457
|
<div
|
|
381
458
|
className={cn(
|
|
@@ -383,7 +460,15 @@ function Notice({ children, className }: { children: ReactNode; className?: stri
|
|
|
383
460
|
className,
|
|
384
461
|
)}
|
|
385
462
|
>
|
|
386
|
-
|
|
463
|
+
<div className="flex max-w-sm flex-col items-center gap-2.5">
|
|
464
|
+
{icon ? (
|
|
465
|
+
<span className="grid size-10 place-items-center rounded-og-lg border border-og-border bg-og-surface-1 text-og-fg-muted shadow-sm">
|
|
466
|
+
{icon}
|
|
467
|
+
</span>
|
|
468
|
+
) : null}
|
|
469
|
+
{title ? <p className="font-medium text-og-fg">{title}</p> : null}
|
|
470
|
+
<div className="leading-5">{children}</div>
|
|
471
|
+
</div>
|
|
387
472
|
</div>
|
|
388
473
|
);
|
|
389
474
|
}
|
|
@@ -16,7 +16,14 @@
|
|
|
16
16
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
17
17
|
import { Popover } from "radix-ui";
|
|
18
18
|
import type { SessionEvent } from "@opengeni/sdk";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
CircleCheckIcon,
|
|
21
|
+
CpuIcon,
|
|
22
|
+
LaptopIcon,
|
|
23
|
+
LoaderCircleIcon,
|
|
24
|
+
RefreshCwIcon,
|
|
25
|
+
TriangleAlertIcon,
|
|
26
|
+
} from "lucide-react";
|
|
20
27
|
|
|
21
28
|
import { type ClientOverride, useOpenGeni } from "../provider";
|
|
22
29
|
import { cn } from "../lib/cn";
|
|
@@ -62,11 +69,9 @@ function captureDegradedMessage(reason: string): string {
|
|
|
62
69
|
* "changes exist → Changes, else Files" needs zero machine round-trips. A host
|
|
63
70
|
* `override` (e.g. a landing "run" tab) wins when supplied.
|
|
64
71
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* 2). This remains exported as a standalone helper for hosts that already hold
|
|
69
|
-
* the event log and want the same decision without the capture fetch.
|
|
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.
|
|
70
75
|
*/
|
|
71
76
|
export function initialWorkspaceTab(
|
|
72
77
|
events: SessionEvent[] | undefined,
|
|
@@ -131,22 +136,28 @@ export type UseSandboxWorkspaceTabsOptions = ClientOverride & {
|
|
|
131
136
|
sessionId: string;
|
|
132
137
|
/** Live event log (usually `useSessionEvents().events`). */
|
|
133
138
|
events: SessionEvent[];
|
|
134
|
-
/** Override the
|
|
135
|
-
* omitted the workbench picks Changes-vs-Files from
|
|
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. */
|
|
136
141
|
initialTab?: string | null | undefined;
|
|
137
142
|
/** Host-routed notifications (mutation errors, desktop-consent failures). The
|
|
138
143
|
* package never imports a toast library — the host decides how to surface. */
|
|
139
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;
|
|
140
153
|
};
|
|
141
154
|
|
|
142
155
|
export type UseSandboxWorkspaceTabsResult = {
|
|
143
156
|
/** Changes | Files | Terminal | Desktop (capability-gated where noted). */
|
|
144
157
|
tabs: WorkspaceTab[];
|
|
145
|
-
/** The
|
|
146
|
-
* Files (a host `initialTab` overrides).
|
|
147
|
-
*
|
|
148
|
-
* their dock's own first-tab fallback until it latches, which it does ONCE, so it
|
|
149
|
-
* never causes a post-render switch. */
|
|
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. */
|
|
150
161
|
defaultTab: string | null;
|
|
151
162
|
/** The machine-state model for the dock-header chip. */
|
|
152
163
|
machine: WorkspaceMachine;
|
|
@@ -156,7 +167,7 @@ type SessionWarmIntents = {
|
|
|
156
167
|
sessionId: string;
|
|
157
168
|
watchDesktop: boolean;
|
|
158
169
|
warmTerminal: boolean;
|
|
159
|
-
|
|
170
|
+
warmFiles: boolean;
|
|
160
171
|
};
|
|
161
172
|
|
|
162
173
|
function emptyWarmIntents(sessionId: string): SessionWarmIntents {
|
|
@@ -164,7 +175,7 @@ function emptyWarmIntents(sessionId: string): SessionWarmIntents {
|
|
|
164
175
|
sessionId,
|
|
165
176
|
watchDesktop: false,
|
|
166
177
|
warmTerminal: false,
|
|
167
|
-
|
|
178
|
+
warmFiles: false,
|
|
168
179
|
};
|
|
169
180
|
}
|
|
170
181
|
|
|
@@ -177,20 +188,21 @@ export function useSandboxWorkspaceTabs(
|
|
|
177
188
|
options: UseSandboxWorkspaceTabsOptions,
|
|
178
189
|
): UseSandboxWorkspaceTabsResult {
|
|
179
190
|
const { client, workspaceId } = useOpenGeni(options);
|
|
180
|
-
const { sessionId, events, onNotify } =
|
|
191
|
+
const { sessionId, events, onNotify, requestedFilePath, requestedFileRequestId, onOpenFile } =
|
|
192
|
+
options;
|
|
181
193
|
const initialTab = options.initialTab ?? null;
|
|
182
194
|
|
|
183
|
-
// The three
|
|
195
|
+
// The three box-warming INTENTS, each off by default and each
|
|
184
196
|
// flipped true by a genuine user action (never on mount, never on a passive
|
|
185
197
|
// capture glance): desktop watch consent, terminal engagement (`onActivate`), and
|
|
186
|
-
//
|
|
198
|
+
// a deliberate live-file open/edit in Files. Browsing capture-served
|
|
187
199
|
// Changes/Files warms nothing — that is the whole point of Refinement 1.
|
|
188
200
|
const [storedWarmIntents, setStoredWarmIntents] = useState<SessionWarmIntents>(() =>
|
|
189
201
|
emptyWarmIntents(sessionId),
|
|
190
202
|
);
|
|
191
203
|
const warmIntents =
|
|
192
204
|
storedWarmIntents.sessionId === sessionId ? storedWarmIntents : emptyWarmIntents(sessionId);
|
|
193
|
-
const { watchDesktop, warmTerminal,
|
|
205
|
+
const { watchDesktop, warmTerminal, warmFiles } = warmIntents;
|
|
194
206
|
const requestWarmIntent = useCallback(
|
|
195
207
|
(intent: Exclude<keyof SessionWarmIntents, "sessionId">) => {
|
|
196
208
|
setStoredWarmIntents((previous) => {
|
|
@@ -217,9 +229,9 @@ export function useSandboxWorkspaceTabs(
|
|
|
217
229
|
events,
|
|
218
230
|
attachDesktop: watchDesktop,
|
|
219
231
|
attachTerminal: warmTerminal,
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
attachFiles:
|
|
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,
|
|
223
235
|
});
|
|
224
236
|
const capabilities = caps.capabilities;
|
|
225
237
|
const liveness = capabilities?.liveness;
|
|
@@ -363,18 +375,18 @@ export function useSandboxWorkspaceTabs(
|
|
|
363
375
|
capabilitiesState: caps.state,
|
|
364
376
|
activeMachineState: activeMachine?.state ?? null,
|
|
365
377
|
activeIsSelfhosted: activeMachine?.kind === "selfhosted",
|
|
366
|
-
wantsWarm: warmTerminal || watchDesktop ||
|
|
378
|
+
wantsWarm: warmTerminal || watchDesktop || warmFiles,
|
|
367
379
|
capturedAt: captureState.capturedAt,
|
|
368
380
|
});
|
|
369
381
|
|
|
370
|
-
// The pre-paint default tab
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
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.
|
|
378
390
|
const defaultTabRef = useRef<{ sessionId: string; value: string | null }>({
|
|
379
391
|
sessionId,
|
|
380
392
|
value: null,
|
|
@@ -382,12 +394,42 @@ export function useSandboxWorkspaceTabs(
|
|
|
382
394
|
if (defaultTabRef.current.sessionId !== sessionId) {
|
|
383
395
|
defaultTabRef.current = { sessionId, value: null };
|
|
384
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;
|
|
385
401
|
if (defaultTabRef.current.value === null) {
|
|
386
402
|
if (initialTab) {
|
|
387
403
|
defaultTabRef.current.value = initialTab;
|
|
388
|
-
} else if (
|
|
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.
|
|
389
423
|
defaultTabRef.current.value =
|
|
390
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;
|
|
391
433
|
}
|
|
392
434
|
}
|
|
393
435
|
// null only during the brief pre-first-resolve window (no host override yet).
|
|
@@ -411,6 +453,14 @@ export function useSandboxWorkspaceTabs(
|
|
|
411
453
|
capabilitiesState={caps.state}
|
|
412
454
|
capabilitiesError={caps.error}
|
|
413
455
|
onRetry={caps.renegotiate}
|
|
456
|
+
{...(onOpenFile
|
|
457
|
+
? {
|
|
458
|
+
onOpenFile: (path: string) => {
|
|
459
|
+
requestWarmIntent("warmFiles");
|
|
460
|
+
onOpenFile(path);
|
|
461
|
+
},
|
|
462
|
+
}
|
|
463
|
+
: {})}
|
|
414
464
|
/>
|
|
415
465
|
),
|
|
416
466
|
});
|
|
@@ -421,14 +471,24 @@ export function useSandboxWorkspaceTabs(
|
|
|
421
471
|
label: "Files",
|
|
422
472
|
content: (
|
|
423
473
|
<SandboxFiles
|
|
474
|
+
key={sessionId}
|
|
424
475
|
files={files}
|
|
425
476
|
git={git}
|
|
426
477
|
stagedGit={stagedGit}
|
|
427
478
|
fileSystemAvailable={fileSystemOn || captureAvailable}
|
|
428
479
|
editable={filesEditable}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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")}
|
|
432
492
|
className="h-full"
|
|
433
493
|
/>
|
|
434
494
|
),
|
|
@@ -488,6 +548,11 @@ export function useSandboxWorkspaceTabs(
|
|
|
488
548
|
dirtyCount,
|
|
489
549
|
watchDesktop,
|
|
490
550
|
warmTerminal,
|
|
551
|
+
requestedFilePath,
|
|
552
|
+
requestedFileRequestId,
|
|
553
|
+
onOpenFile,
|
|
554
|
+
liveness,
|
|
555
|
+
sessionId,
|
|
491
556
|
files,
|
|
492
557
|
git,
|
|
493
558
|
stagedGit,
|
|
@@ -524,7 +589,7 @@ export type SandboxWorkspaceProps = ClientOverride & {
|
|
|
524
589
|
/** Host tabs injected AFTER the workbench tabs (e.g. a "Debug" tab). */
|
|
525
590
|
trailingTabs?: WorkspaceTab[] | undefined;
|
|
526
591
|
/** Override the pre-paint default tab (e.g. a host landing tab id). When
|
|
527
|
-
* omitted the workbench decides
|
|
592
|
+
* omitted the workbench decides from capture, then live Git when no capture exists. */
|
|
528
593
|
initialTab?: string | undefined;
|
|
529
594
|
/** Host-routed notifications (no toast dependency in the package). */
|
|
530
595
|
onNotify?: ((notification: WorkspaceNotification) => void) | undefined;
|
|
@@ -566,6 +631,25 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
|
|
|
566
631
|
className,
|
|
567
632
|
} = props;
|
|
568
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
|
+
|
|
569
653
|
const {
|
|
570
654
|
tabs: workbenchTabs,
|
|
571
655
|
machine,
|
|
@@ -577,18 +661,17 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
|
|
|
577
661
|
events,
|
|
578
662
|
...(initialTab ? { initialTab } : {}),
|
|
579
663
|
...(onNotify ? { onNotify } : {}),
|
|
664
|
+
requestedFilePath: requestedFile?.sessionId === sessionId ? requestedFile.path : null,
|
|
665
|
+
requestedFileRequestId: requestedFile?.sessionId === sessionId ? requestedFile.requestId : null,
|
|
666
|
+
onOpenFile: openFile,
|
|
580
667
|
});
|
|
581
668
|
|
|
582
|
-
// A user's tab click wins forever; before that we follow the
|
|
669
|
+
// A user's tab click wins forever; before that we follow the source-driven
|
|
583
670
|
// default. While it is still resolving (null, pure-embedder pre-first-resolve)
|
|
584
671
|
// we pass no controlled tab, so the dock renders its own first-tab fallback
|
|
585
672
|
// (Changes) — whose body is a connecting/loading state until the capture lands,
|
|
586
673
|
// so committing the real default at first-resolve produces no CONTENT switch.
|
|
587
674
|
const tabs: WorkspaceTab[] = [...(leadingTabs ?? []), ...workbenchTabs, ...(trailingTabs ?? [])];
|
|
588
|
-
const [storedSelection, setStoredSelection] = useState<{
|
|
589
|
-
sessionId: string;
|
|
590
|
-
tab: string;
|
|
591
|
-
} | null>(null);
|
|
592
675
|
const selectedTab = storedSelection?.sessionId === sessionId ? storedSelection.tab : null;
|
|
593
676
|
const activeTab = selectedTab ?? defaultTab ?? tabs[0]?.id;
|
|
594
677
|
const selectTab = useCallback(
|
|
@@ -645,7 +728,7 @@ function MachineKindIcon({
|
|
|
645
728
|
|
|
646
729
|
function chipDotClass(state: MachineChip["state"]): string {
|
|
647
730
|
if (state === "live") return "bg-og-status-running";
|
|
648
|
-
if (state === "waking") return "bg-og-status-idle animate-pulse";
|
|
731
|
+
if (state === "waking") return "bg-og-status-idle animate-pulse motion-reduce:animate-none";
|
|
649
732
|
return "bg-og-fg-subtle";
|
|
650
733
|
}
|
|
651
734
|
|
|
@@ -673,7 +756,7 @@ function MachineStateChip({
|
|
|
673
756
|
<button
|
|
674
757
|
type="button"
|
|
675
758
|
aria-label={`Machine: ${chip.label}`}
|
|
676
|
-
className="inline-flex min-h-7 items-center gap-1.5 rounded-og-sm px-2 py-1 text-og-xs font-medium text-og-fg-muted transition-colors hover:bg-og-surface-2 hover:text-og-fg max-[1023px]:min-h-11 pointer-coarse:min-h-11"
|
|
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"
|
|
677
760
|
>
|
|
678
761
|
<span
|
|
679
762
|
className={cn("size-1.5 shrink-0 rounded-full", chipDotClass(chip.state))}
|
|
@@ -740,7 +823,7 @@ function DockActionButton({ onClick, children }: { onClick: () => void; children
|
|
|
740
823
|
<button
|
|
741
824
|
type="button"
|
|
742
825
|
onClick={onClick}
|
|
743
|
-
className="inline-flex items-center gap-1.5 rounded-og-sm border border-og-border px-2 py-1 text-og-xs font-medium text-og-fg-muted transition-colors hover:border-og-border-strong hover:text-og-fg pointer-coarse:min-h-
|
|
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"
|
|
744
827
|
>
|
|
745
828
|
{children}
|
|
746
829
|
</button>
|
|
@@ -760,6 +843,7 @@ function ChangesTabBody({
|
|
|
760
843
|
capabilitiesState,
|
|
761
844
|
capabilitiesError,
|
|
762
845
|
onRetry,
|
|
846
|
+
onOpenFile,
|
|
763
847
|
}: {
|
|
764
848
|
git: UseSandboxGitResult;
|
|
765
849
|
captureAvailable: boolean;
|
|
@@ -767,6 +851,7 @@ function ChangesTabBody({
|
|
|
767
851
|
capabilitiesState: string;
|
|
768
852
|
capabilitiesError: Error | null;
|
|
769
853
|
onRetry: () => void;
|
|
854
|
+
onOpenFile?: ((path: string) => void) | undefined;
|
|
770
855
|
}) {
|
|
771
856
|
const diff = git.diff;
|
|
772
857
|
|
|
@@ -777,13 +862,14 @@ function ChangesTabBody({
|
|
|
777
862
|
source={git.source}
|
|
778
863
|
capturedAt={git.capturedAt}
|
|
779
864
|
captureRevision={captureRevision}
|
|
865
|
+
{...(onOpenFile ? { onOpenFile } : {})}
|
|
780
866
|
/>
|
|
781
867
|
);
|
|
782
868
|
}
|
|
783
869
|
|
|
784
870
|
if (capabilitiesError && !captureAvailable) {
|
|
785
871
|
return (
|
|
786
|
-
<CenteredState>
|
|
872
|
+
<CenteredState icon={<TriangleAlertIcon className="size-5" aria-hidden />} tone="danger">
|
|
787
873
|
<p className="text-og-sm font-medium text-og-fg">Sandbox unavailable</p>
|
|
788
874
|
<p className="text-og-sm leading-5 text-og-fg-muted">
|
|
789
875
|
{capabilitiesError.message || "Couldn't reach the sandbox for this session."}
|
|
@@ -798,26 +884,74 @@ function ChangesTabBody({
|
|
|
798
884
|
|
|
799
885
|
if ((capabilitiesState === "negotiating" || capabilitiesState === "cold") && !captureAvailable) {
|
|
800
886
|
return (
|
|
801
|
-
<CenteredState
|
|
802
|
-
|
|
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>
|
|
803
915
|
</CenteredState>
|
|
804
916
|
);
|
|
805
917
|
}
|
|
806
918
|
|
|
807
919
|
return (
|
|
808
|
-
<CenteredState>
|
|
809
|
-
<p className="text-og-sm font-medium text-og-fg">
|
|
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>
|
|
810
922
|
<p className="text-og-sm leading-5 text-og-fg-subtle">
|
|
811
|
-
File edits from
|
|
923
|
+
File edits from future turns will appear here.
|
|
812
924
|
</p>
|
|
813
925
|
</CenteredState>
|
|
814
926
|
);
|
|
815
927
|
}
|
|
816
928
|
|
|
817
|
-
function CenteredState({
|
|
929
|
+
function CenteredState({
|
|
930
|
+
children,
|
|
931
|
+
icon,
|
|
932
|
+
tone = "neutral",
|
|
933
|
+
}: {
|
|
934
|
+
children: ReactNode;
|
|
935
|
+
icon?: ReactNode | undefined;
|
|
936
|
+
tone?: "neutral" | "success" | "danger";
|
|
937
|
+
}) {
|
|
818
938
|
return (
|
|
819
939
|
<div className="grid h-full place-items-center p-6 text-center">
|
|
820
|
-
<div className="flex max-w-sm flex-col items-center gap-2.5">
|
|
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>
|
|
821
955
|
</div>
|
|
822
956
|
);
|
|
823
957
|
}
|
|
@@ -35,12 +35,6 @@ export const SESSION_STATUS_META: Record<SessionStatusValue, SessionStatusMeta>
|
|
|
35
35
|
badgeClassName: "text-og-status-waiting border-og-status-waiting/35 bg-og-status-waiting/10",
|
|
36
36
|
pulse: true,
|
|
37
37
|
},
|
|
38
|
-
paused: {
|
|
39
|
-
label: "Paused",
|
|
40
|
-
dotClassName: "bg-og-status-cancelled",
|
|
41
|
-
badgeClassName: "text-og-fg-subtle border-og-border bg-og-status-cancelled/10",
|
|
42
|
-
pulse: false,
|
|
43
|
-
},
|
|
44
38
|
idle: {
|
|
45
39
|
label: "Idle",
|
|
46
40
|
dotClassName: "bg-og-status-idle",
|