@opengeni/react 0.15.0 → 0.23.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 +110 -10
- package/dist/{chunk-TOJR776I.js → chunk-6XUS5VFM.js} +45 -369
- package/dist/chunk-6XUS5VFM.js.map +1 -0
- package/dist/chunk-CATATY77.js +2393 -0
- package/dist/chunk-CATATY77.js.map +1 -0
- package/dist/chunk-I3BJZIG5.js +439 -0
- package/dist/chunk-I3BJZIG5.js.map +1 -0
- package/dist/chunk-TK7G6XLT.js +18 -0
- package/dist/chunk-TK7G6XLT.js.map +1 -0
- package/dist/chunk-UAHPKLB6.js +2237 -0
- package/dist/chunk-UAHPKLB6.js.map +1 -0
- package/dist/chunk-UQS7OQDE.js +591 -0
- package/dist/chunk-UQS7OQDE.js.map +1 -0
- package/dist/chunk-YDNWMKXO.js +102 -0
- package/dist/chunk-YDNWMKXO.js.map +1 -0
- package/dist/composer-CVm5uoUI.d.ts +585 -0
- package/dist/composer.d.ts +6 -0
- package/dist/composer.js +56 -0
- package/dist/composer.js.map +1 -0
- package/dist/index.d.ts +1288 -1785
- package/dist/index.js +7140 -8817
- package/dist/index.js.map +1 -1
- package/dist/machines.d.ts +412 -3
- package/dist/machines.js +3 -1
- package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
- package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
- package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
- package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
- package/dist/queue-surface-implementation-3UW3MIIR.js +890 -0
- package/dist/queue-surface-implementation-3UW3MIIR.js.map +1 -0
- package/dist/session-Db00TRwl.d.ts +446 -0
- package/dist/session-context-rgrfElMl.d.ts +58 -0
- package/dist/session.d.ts +4 -0
- package/dist/session.js +54 -0
- package/dist/session.js.map +1 -0
- package/dist/use-composer-By1wn3MM.d.ts +99 -0
- package/package.json +20 -4
- package/src/approvals.ts +5 -3
- package/src/client.ts +44 -0
- package/src/components/approval-surface.tsx +198 -0
- package/src/components/chat-composer.tsx +94 -993
- package/src/components/code-editor.tsx +104 -26
- package/src/components/command-palette.tsx +8 -2
- package/src/components/composer-transcription-control.tsx +211 -0
- package/src/components/composer.tsx +1536 -0
- package/src/components/desktop-viewer.tsx +3 -3
- package/src/components/enrollment-consent.tsx +2 -2
- package/src/components/file-browser.tsx +367 -29
- package/src/components/human-input-form.tsx +417 -0
- package/src/components/machine-card.tsx +1 -1
- package/src/components/machines-dashboard.tsx +3 -3
- package/src/components/message-timeline.tsx +205 -25
- package/src/components/model-picker.tsx +12 -3
- package/src/components/pierre-diff.tsx +32 -6
- package/src/components/queue-draft-policy.ts +18 -0
- package/src/components/queue-surface-implementation.tsx +1037 -0
- package/src/components/queue-surface-state.tsx +94 -0
- package/src/components/queue-surface.tsx +42 -559
- package/src/components/sandbox-files.tsx +138 -14
- package/src/components/sandbox-terminal.tsx +8 -2
- package/src/components/sandbox-workspace.tsx +339 -119
- package/src/components/workbench-changes.tsx +84 -33
- package/src/components/workspace-dock.tsx +188 -18
- package/src/composer.ts +60 -0
- package/src/hooks/internal.ts +115 -34
- package/src/hooks/use-composer.ts +385 -104
- package/src/hooks/use-human-input.ts +131 -0
- package/src/hooks/use-machine-chip.ts +2 -2
- package/src/hooks/use-machines.ts +27 -11
- package/src/hooks/use-sandbox-files.ts +310 -62
- package/src/hooks/use-sandbox-git.ts +154 -40
- package/src/hooks/use-sandbox-terminal.ts +28 -6
- package/src/hooks/use-session-capabilities.ts +38 -9
- package/src/hooks/use-session-control.ts +37 -8
- package/src/hooks/use-session-events.ts +522 -48
- package/src/hooks/use-session-mcp-approval-policy.ts +161 -0
- package/src/hooks/use-slash-commands.ts +38 -38
- package/src/hooks/use-transcription.ts +757 -0
- package/src/hooks/use-turn-queue.ts +134 -66
- package/src/hooks/use-windowed-sections.ts +2 -2
- package/src/hooks/use-workspace-capture.ts +146 -40
- package/src/hooks/use-workspace-edit.ts +50 -14
- package/src/hooks/use-workspace-sessions.ts +3 -0
- package/src/human-input.ts +72 -0
- package/src/index.ts +85 -6
- package/src/lib/noto-sans-arabic-loader.ts +21 -0
- package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
- package/src/lib/use-portal-token-style.ts +51 -0
- package/src/lib/use-unicode-fonts.ts +57 -0
- package/src/provider.tsx +4 -69
- package/src/session-context.ts +160 -0
- package/src/session.ts +89 -0
- package/src/timeline/parsers.ts +35 -0
- package/src/timeline/projection.ts +12 -4
- package/src/timeline/tool-renderers.tsx +41 -0
- package/src/timeline/turn-summary.tsx +2 -2
- package/src/timeline/types.ts +10 -9
- package/styles/index.css +56 -0
- package/styles/index.d.ts +2 -0
- package/styles/tokens.css +11 -6
- package/styles/tokens.d.ts +2 -0
- package/dist/chunk-TOJR776I.js.map +0 -1
- package/dist/machines-BpdwuQcD.d.ts +0 -449
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
FsWriteResponse,
|
|
6
6
|
GitChangedPayload,
|
|
7
7
|
GitFileStatusCode,
|
|
8
|
+
OpenGeniRequestOptions,
|
|
8
9
|
SessionEvent,
|
|
9
10
|
WorkspaceCaptureManifest,
|
|
10
11
|
} from "@opengeni/sdk";
|
|
@@ -14,6 +15,50 @@ import { useOpenGeni, type ClientOverride } from "../provider";
|
|
|
14
15
|
/** The git-status overlay a file row may carry (tints modified files in the tree). */
|
|
15
16
|
export type FileTreeStatus = "added" | "modified" | "deleted" | "renamed" | "untracked";
|
|
16
17
|
|
|
18
|
+
export type CapturedFileUnavailableReason = "not-captured" | "too-large" | "content-missing";
|
|
19
|
+
|
|
20
|
+
/** A cold capture can index a path without retaining its contents. Components
|
|
21
|
+
* use this typed result to offer an explicit wake action instead of silently
|
|
22
|
+
* turning passive review into a live sandbox read. */
|
|
23
|
+
export class CapturedFileUnavailableError extends Error {
|
|
24
|
+
readonly code = "captured_file_unavailable";
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
readonly path: string,
|
|
28
|
+
readonly reason: CapturedFileUnavailableReason,
|
|
29
|
+
) {
|
|
30
|
+
super(
|
|
31
|
+
reason === "too-large"
|
|
32
|
+
? `${path} exceeds the captured preview limit.`
|
|
33
|
+
: reason === "content-missing"
|
|
34
|
+
? `${path} is no longer available in this capture.`
|
|
35
|
+
: `${path} was not changed in this captured turn.`,
|
|
36
|
+
);
|
|
37
|
+
this.name = "CapturedFileUnavailableError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class FileWriteConflictError extends Error {
|
|
42
|
+
readonly code = "file_write_conflict";
|
|
43
|
+
|
|
44
|
+
constructor(
|
|
45
|
+
readonly path: string,
|
|
46
|
+
readonly expectedContent: string,
|
|
47
|
+
readonly liveContent: string,
|
|
48
|
+
) {
|
|
49
|
+
super(`${path} changed on the machine. Reload it or explicitly overwrite the live version.`);
|
|
50
|
+
this.name = "FileWriteConflictError";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type SandboxWriteFileOptions = {
|
|
55
|
+
/** Exact text loaded into the editor. The live file is re-read before write;
|
|
56
|
+
* divergence fails closed with FileWriteConflictError. */
|
|
57
|
+
expectedContent?: string | undefined;
|
|
58
|
+
/** Deliberately bypass the expected-content guard after a visible conflict. */
|
|
59
|
+
force?: boolean | undefined;
|
|
60
|
+
};
|
|
61
|
+
|
|
17
62
|
/** A node in the Pierre file tree. `children === undefined` ⇒ an unexpanded dir
|
|
18
63
|
* (lazy treeMode); `children: []` ⇒ an expanded-but-empty dir. */
|
|
19
64
|
export type FileTreeNode = {
|
|
@@ -43,11 +88,10 @@ export type UseSandboxFilesOptions = ClientOverride & {
|
|
|
43
88
|
* (with no `fs.changed` event) never re-lists. Passing liveness re-lists when
|
|
44
89
|
* the box first becomes warm, so the tree populates as soon as the box is up. */
|
|
45
90
|
liveness?: string | undefined;
|
|
46
|
-
/** The latest turn-end workspace capture (from `useWorkspaceCapture`).
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* is merged in place — no remount, no flash (dossier §10.4 / §12-A1/D1). */
|
|
91
|
+
/** The latest turn-end workspace capture (from `useWorkspaceCapture`). The tree
|
|
92
|
+
* paints immediately from this durable index, then a warm box reconciles live
|
|
93
|
+
* in place. If that live read fails, the capture remains a truthful read-only
|
|
94
|
+
* fallback instead of turning into an empty error surface. */
|
|
51
95
|
capture?: WorkspaceCaptureManifest | null | undefined;
|
|
52
96
|
/** Called when an OPTIMISTIC mutation is reverted because its background
|
|
53
97
|
* Channel-A op failed (e.g. a 409 rename collision). The host wires this to a
|
|
@@ -64,11 +108,15 @@ export type UseSandboxFilesResult = {
|
|
|
64
108
|
* spinner on these nodes so a 2-3s Channel-A list never looks frozen. */
|
|
65
109
|
expandingPaths: Set<string>;
|
|
66
110
|
/** Read a file for the preview pane (text or base64-for-binary, size-capped). */
|
|
67
|
-
readFile: (path: string) => Promise<FsReadResponse>;
|
|
111
|
+
readFile: (path: string, options?: OpenGeniRequestOptions) => Promise<FsReadResponse>;
|
|
68
112
|
/** Write a file (overwrite, last-writer-wins) — the editor save path.
|
|
69
113
|
* Optimistic: a brand-new file is spliced into the tree immediately and the
|
|
70
114
|
* Channel-A write runs in the background; on failure the splice is reverted. */
|
|
71
|
-
writeFile: (
|
|
115
|
+
writeFile: (
|
|
116
|
+
path: string,
|
|
117
|
+
content: string,
|
|
118
|
+
options?: SandboxWriteFileOptions,
|
|
119
|
+
) => Promise<FsWriteResponse>;
|
|
72
120
|
/** Create a new empty file (refuses to clobber an existing path: overwrite=false). */
|
|
73
121
|
createFile: (path: string) => Promise<void>;
|
|
74
122
|
/** Create a directory (recursive by default). */
|
|
@@ -111,7 +159,7 @@ function sortNodes(nodes: FileTreeNode[]): FileTreeNode[] {
|
|
|
111
159
|
});
|
|
112
160
|
}
|
|
113
161
|
|
|
114
|
-
function fsNodeToTree(node: FsTreeNode): FileTreeNode {
|
|
162
|
+
function fsNodeToTree(node: FsTreeNode, captureComplete = false): FileTreeNode {
|
|
115
163
|
const kind = node.type === "dir" ? "dir" : "file";
|
|
116
164
|
// Lazy-tree contract: a depth-bounded `fsList` returns each directory at the
|
|
117
165
|
// depth boundary with `children: []` (the dir is listed, but its grandchildren
|
|
@@ -123,7 +171,11 @@ function fsNodeToTree(node: FsTreeNode): FileTreeNode {
|
|
|
123
171
|
// children spliced in by `replaceChildren` (bypassing this mapper), so a
|
|
124
172
|
// genuinely-empty dir correctly ends up as `[]` AFTER expansion.
|
|
125
173
|
const mappedChildren =
|
|
126
|
-
node.children && node.children.length > 0
|
|
174
|
+
node.children && node.children.length > 0
|
|
175
|
+
? node.children.map((child) => fsNodeToTree(child, captureComplete))
|
|
176
|
+
: captureComplete && kind === "dir"
|
|
177
|
+
? []
|
|
178
|
+
: undefined;
|
|
127
179
|
return {
|
|
128
180
|
path: node.path,
|
|
129
181
|
name: node.name,
|
|
@@ -291,6 +343,40 @@ function repathNode(node: FileTreeNode, fromPrefix: string, toPrefix: string): F
|
|
|
291
343
|
return next;
|
|
292
344
|
}
|
|
293
345
|
|
|
346
|
+
function decodeBase64Bytes(value: string): Uint8Array {
|
|
347
|
+
const binary = atob(value);
|
|
348
|
+
const bytes = new Uint8Array(binary.length);
|
|
349
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
350
|
+
bytes[index] = binary.charCodeAt(index);
|
|
351
|
+
}
|
|
352
|
+
return bytes;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function decodeBase64Text(value: string): string {
|
|
356
|
+
return new TextDecoder().decode(decodeBase64Bytes(value));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function encodeBase64Bytes(bytes: Uint8Array): string {
|
|
360
|
+
let binary = "";
|
|
361
|
+
const chunkSize = 0x8000;
|
|
362
|
+
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
|
363
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
|
|
364
|
+
}
|
|
365
|
+
return btoa(binary);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function assertCapturedHash(bytes: Uint8Array, expected: string | null): Promise<void> {
|
|
369
|
+
if (!expected) throw new Error("Captured file is missing its integrity hash.");
|
|
370
|
+
if (!globalThis.crypto?.subtle) {
|
|
371
|
+
throw new Error("Captured file integrity verification is unavailable in this browser.");
|
|
372
|
+
}
|
|
373
|
+
const buffer = new ArrayBuffer(bytes.byteLength);
|
|
374
|
+
new Uint8Array(buffer).set(bytes);
|
|
375
|
+
const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", buffer));
|
|
376
|
+
const actual = [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
377
|
+
if (actual !== expected) throw new Error("Captured file content failed its integrity check.");
|
|
378
|
+
}
|
|
379
|
+
|
|
294
380
|
/**
|
|
295
381
|
* Project the FileSystem service into a lazy-loaded Pierre tree. The initial
|
|
296
382
|
* list pulls one level (depth 1); `expand(path)` lists a directory's immediate
|
|
@@ -305,6 +391,9 @@ export function useSandboxFiles(
|
|
|
305
391
|
const { client, workspaceId } = useOpenGeni(options);
|
|
306
392
|
const enabled = (options.enabled ?? true) && Boolean(sessionId);
|
|
307
393
|
const rootPath = options.rootPath ?? "";
|
|
394
|
+
const capture = options.capture ?? null;
|
|
395
|
+
const isLive = options.liveness === "warm" || options.liveness === "draining";
|
|
396
|
+
const identityKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${rootPath}`;
|
|
308
397
|
|
|
309
398
|
const [tree, setTree] = useState<FileTreeNode[]>([]);
|
|
310
399
|
// A ref mirror of the current tree — lets the optimistic path snapshot the
|
|
@@ -315,12 +404,16 @@ export function useSandboxFiles(
|
|
|
315
404
|
const [loading, setLoading] = useState(false);
|
|
316
405
|
const [expandingPaths, setExpandingPaths] = useState<Set<string>>(new Set());
|
|
317
406
|
const [error, setError] = useState<Error | null>(null);
|
|
407
|
+
const [stateIdentity, setStateIdentity] = useState(identityKey);
|
|
318
408
|
// Which source the tree currently reflects — "capture" (cold paint) or "live".
|
|
319
409
|
const [source, setSource] = useState<"live" | "capture" | null>(null);
|
|
410
|
+
const sourceRef = useRef<"live" | "capture" | null>(null);
|
|
320
411
|
const statusRef = useRef<Map<string, FileTreeStatus>>(new Map());
|
|
321
412
|
// Async reads, event cursors, and debounced work are scoped to the hook's
|
|
322
413
|
// workspace/session/root identity. These refs are reset/fenced at that boundary.
|
|
323
414
|
const refreshGenerationRef = useRef(0);
|
|
415
|
+
const refreshAbortRef = useRef<AbortController | null>(null);
|
|
416
|
+
const identityAbortRef = useRef(new AbortController());
|
|
324
417
|
const identityGenerationRef = useRef(0);
|
|
325
418
|
const lastSeqRef = useRef(0);
|
|
326
419
|
const pendingParentsRef = useRef<Set<string>>(new Set());
|
|
@@ -344,7 +437,7 @@ export function useSandboxFiles(
|
|
|
344
437
|
// Overlay the git-status tint onto the tree. IDENTITY-PRESERVING: a node whose
|
|
345
438
|
// tint and children are unchanged is returned by reference (not rebuilt), so a
|
|
346
439
|
// re-tint or a cold→warm reconcile does NOT remount unchanged rows — the
|
|
347
|
-
// no-flicker constraint
|
|
440
|
+
// no-flicker constraint. An empty overlay is still
|
|
348
441
|
// meaningful: it strips stale tints after the repository becomes clean.
|
|
349
442
|
const applyStatus = useCallback((nodes: FileTreeNode[]): FileTreeNode[] => {
|
|
350
443
|
const overlay = statusRef.current;
|
|
@@ -372,6 +465,9 @@ export function useSandboxFiles(
|
|
|
372
465
|
|
|
373
466
|
const refresh = useCallback(async () => {
|
|
374
467
|
if (!sessionId) return;
|
|
468
|
+
refreshAbortRef.current?.abort();
|
|
469
|
+
const refreshAbort = new AbortController();
|
|
470
|
+
refreshAbortRef.current = refreshAbort;
|
|
375
471
|
const generation = (refreshGenerationRef.current += 1);
|
|
376
472
|
setLoading(true);
|
|
377
473
|
setError(null);
|
|
@@ -379,7 +475,12 @@ export function useSandboxFiles(
|
|
|
379
475
|
// Pull the git-status overlay first (best-effort — a non-repo box just
|
|
380
476
|
// returns isRepo:false), then the tree, so the first paint is tinted.
|
|
381
477
|
try {
|
|
382
|
-
const status = await client.gitStatus(
|
|
478
|
+
const status = await client.gitStatus(
|
|
479
|
+
workspaceId,
|
|
480
|
+
sessionId,
|
|
481
|
+
{ path: rootPath },
|
|
482
|
+
{ signal: refreshAbort.signal },
|
|
483
|
+
);
|
|
383
484
|
if (refreshGenerationRef.current !== generation) return;
|
|
384
485
|
const overlay = new Map<string, FileTreeStatus>();
|
|
385
486
|
for (const file of status.files) {
|
|
@@ -392,9 +493,14 @@ export function useSandboxFiles(
|
|
|
392
493
|
if (refreshGenerationRef.current !== generation) return;
|
|
393
494
|
statusRef.current = new Map();
|
|
394
495
|
}
|
|
395
|
-
const listed = await client.fsList(
|
|
496
|
+
const listed = await client.fsList(
|
|
497
|
+
workspaceId,
|
|
498
|
+
sessionId,
|
|
499
|
+
{ path: rootPath, depth: 1 },
|
|
500
|
+
{ signal: refreshAbort.signal },
|
|
501
|
+
);
|
|
396
502
|
if (refreshGenerationRef.current !== generation) return;
|
|
397
|
-
const children = (listed.root.children ?? []).map(fsNodeToTree);
|
|
503
|
+
const children = (listed.root.children ?? []).map((node) => fsNodeToTree(node));
|
|
398
504
|
// Merge rather than replace so an explicit refresh / cold→warm re-list folds
|
|
399
505
|
// in new entries WITHOUT collapsing the dirs the user already expanded (and,
|
|
400
506
|
// via the identity-preserving merge, WITHOUT remounting unchanged rows — the
|
|
@@ -403,12 +509,14 @@ export function useSandboxFiles(
|
|
|
403
509
|
applyStatus(prev.length === 0 ? children : mergeRootChildren(prev, children)),
|
|
404
510
|
);
|
|
405
511
|
// Live data is now serving — flip the source off the capture snapshot.
|
|
512
|
+
sourceRef.current = "live";
|
|
406
513
|
setSource("live");
|
|
407
514
|
} catch (cause) {
|
|
408
515
|
if (refreshGenerationRef.current !== generation) return;
|
|
409
516
|
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
410
517
|
} finally {
|
|
411
518
|
if (refreshGenerationRef.current === generation) setLoading(false);
|
|
519
|
+
if (refreshAbortRef.current === refreshAbort) refreshAbortRef.current = null;
|
|
412
520
|
}
|
|
413
521
|
}, [client, workspaceId, sessionId, rootPath, applyStatus]);
|
|
414
522
|
|
|
@@ -416,7 +524,7 @@ export function useSandboxFiles(
|
|
|
416
524
|
// zero Channel-A calls. The tree index is workspace-relative (`treeIndex`); the
|
|
417
525
|
// tint overlay comes from the capture's changed `files`. Uses the SAME merge as
|
|
418
526
|
// the live reconcile so a re-seed (a newer capture arriving cold) patches deltas
|
|
419
|
-
// in place instead of remounting the tree (§12-D1).
|
|
527
|
+
// in place instead of remounting the tree (§12-D1).
|
|
420
528
|
const seedFromCapture = useCallback(
|
|
421
529
|
(manifest: WorkspaceCaptureManifest) => {
|
|
422
530
|
const overlay = new Map<string, FileTreeStatus>();
|
|
@@ -426,10 +534,14 @@ export function useSandboxFiles(
|
|
|
426
534
|
if (mapped) overlay.set(file.path, mapped);
|
|
427
535
|
}
|
|
428
536
|
statusRef.current = overlay;
|
|
429
|
-
|
|
537
|
+
// The capture tree is a complete bounded index. Preserve an explicit empty
|
|
538
|
+
// `children: []` as an actually empty directory; the live depth-1 mapper
|
|
539
|
+
// intentionally treats that shape as an unexpanded boundary.
|
|
540
|
+
const children = (manifest.treeIndex.children ?? []).map((node) => fsNodeToTree(node, true));
|
|
430
541
|
setTree((prev) =>
|
|
431
542
|
applyStatus(prev.length === 0 ? children : mergeRootChildren(prev, children)),
|
|
432
543
|
);
|
|
544
|
+
sourceRef.current = "capture";
|
|
433
545
|
setSource("capture");
|
|
434
546
|
setError(null);
|
|
435
547
|
},
|
|
@@ -439,7 +551,12 @@ export function useSandboxFiles(
|
|
|
439
551
|
const expand = useCallback(
|
|
440
552
|
async (path: string) => {
|
|
441
553
|
if (!sessionId) return;
|
|
554
|
+
// Capture browsing is a durable server-side read surface. Never turn a
|
|
555
|
+
// folder click into a Channel-A list while cold; truncated residue already
|
|
556
|
+
// renders a truthful "contents on machine" row.
|
|
557
|
+
if (source === "capture" && capture) return;
|
|
442
558
|
const identityGeneration = identityGenerationRef.current;
|
|
559
|
+
const identitySignal = identityAbortRef.current.signal;
|
|
443
560
|
// Mark this node as expanding so the FileBrowser can render a spinner while
|
|
444
561
|
// the (often 2-3s) Channel-A fs/list is in flight — the tree never looks
|
|
445
562
|
// frozen on a click.
|
|
@@ -449,9 +566,14 @@ export function useSandboxFiles(
|
|
|
449
566
|
return next;
|
|
450
567
|
});
|
|
451
568
|
try {
|
|
452
|
-
const listed = await client.fsList(
|
|
569
|
+
const listed = await client.fsList(
|
|
570
|
+
workspaceId,
|
|
571
|
+
sessionId,
|
|
572
|
+
{ path, depth: 1 },
|
|
573
|
+
{ signal: identitySignal },
|
|
574
|
+
);
|
|
453
575
|
if (identityGenerationRef.current !== identityGeneration) return;
|
|
454
|
-
const children = (listed.root.children ?? []).map(fsNodeToTree);
|
|
576
|
+
const children = (listed.root.children ?? []).map((node) => fsNodeToTree(node));
|
|
455
577
|
setTree((prev) => applyStatus(replaceChildren(prev, path, children)));
|
|
456
578
|
} catch (cause) {
|
|
457
579
|
if (identityGenerationRef.current !== identityGeneration) return;
|
|
@@ -467,15 +589,102 @@ export function useSandboxFiles(
|
|
|
467
589
|
}
|
|
468
590
|
}
|
|
469
591
|
},
|
|
470
|
-
[client, workspaceId, sessionId, applyStatus],
|
|
592
|
+
[client, workspaceId, sessionId, capture, source, applyStatus],
|
|
471
593
|
);
|
|
472
594
|
|
|
473
595
|
const readFile = useCallback(
|
|
474
|
-
async (path: string) => {
|
|
596
|
+
async (path: string, requestOptions: OpenGeniRequestOptions = {}) => {
|
|
475
597
|
if (!sessionId) throw new Error("no session");
|
|
476
|
-
|
|
598
|
+
const identitySignal = identityAbortRef.current.signal;
|
|
599
|
+
const signal = requestOptions.signal
|
|
600
|
+
? AbortSignal.any([identitySignal, requestOptions.signal])
|
|
601
|
+
: identitySignal;
|
|
602
|
+
const captured = capture?.files.find((file) => file.path === path && !file.deleted);
|
|
603
|
+
if (source === "capture" && capture) {
|
|
604
|
+
if (!captured) {
|
|
605
|
+
throw new CapturedFileUnavailableError(path, "not-captured");
|
|
606
|
+
}
|
|
607
|
+
// A passive review of a turn-touched file must stay entirely server-side:
|
|
608
|
+
// mint/read its captured after-image, verify the content hash, and never
|
|
609
|
+
// wake the sandbox. A URL may expire between mint and GET, so retry once
|
|
610
|
+
// through the authenticated API to obtain a fresh scoped URL.
|
|
611
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
612
|
+
const result = await client.getWorkspaceCaptureFile(
|
|
613
|
+
workspaceId,
|
|
614
|
+
sessionId,
|
|
615
|
+
path,
|
|
616
|
+
capture.revision,
|
|
617
|
+
{ signal },
|
|
618
|
+
);
|
|
619
|
+
if (result.path !== path || result.revision !== capture.revision) {
|
|
620
|
+
throw new Error(
|
|
621
|
+
"Captured file identity did not match the requested workspace revision.",
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
if (
|
|
625
|
+
result.hash !== captured.hash ||
|
|
626
|
+
result.sizeBytes !== captured.sizeBytes ||
|
|
627
|
+
result.isBinary !== captured.isBinary ||
|
|
628
|
+
result.tooLarge !== captured.tooLarge
|
|
629
|
+
) {
|
|
630
|
+
throw new Error("Captured file metadata did not match its manifest.");
|
|
631
|
+
}
|
|
632
|
+
if (result.tooLarge) {
|
|
633
|
+
throw new CapturedFileUnavailableError(path, "too-large");
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
let bytes: Uint8Array;
|
|
637
|
+
let encoding: "utf8" | "base64";
|
|
638
|
+
let content: string;
|
|
639
|
+
if (result.content !== null && result.encoding !== null) {
|
|
640
|
+
encoding = result.encoding;
|
|
641
|
+
content = result.content;
|
|
642
|
+
bytes =
|
|
643
|
+
encoding === "base64"
|
|
644
|
+
? decodeBase64Bytes(content)
|
|
645
|
+
: new TextEncoder().encode(content);
|
|
646
|
+
} else if (result.contentUrl) {
|
|
647
|
+
const response = await fetch(result.contentUrl.url, {
|
|
648
|
+
credentials: "omit",
|
|
649
|
+
cache: "no-store",
|
|
650
|
+
referrerPolicy: "no-referrer",
|
|
651
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
|
|
652
|
+
}).catch(() => null);
|
|
653
|
+
if (!response?.ok) {
|
|
654
|
+
if (
|
|
655
|
+
attempt === 0 &&
|
|
656
|
+
(response === null || response.status === 401 || response.status === 403)
|
|
657
|
+
) {
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
throw new Error("Captured file download failed.");
|
|
661
|
+
}
|
|
662
|
+
bytes = new Uint8Array(await response.arrayBuffer());
|
|
663
|
+
encoding = result.isBinary ? "base64" : "utf8";
|
|
664
|
+
content = result.isBinary ? encodeBase64Bytes(bytes) : new TextDecoder().decode(bytes);
|
|
665
|
+
} else {
|
|
666
|
+
throw new CapturedFileUnavailableError(path, "content-missing");
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (bytes.byteLength !== result.sizeBytes) {
|
|
670
|
+
throw new Error("Captured file size did not match its manifest.");
|
|
671
|
+
}
|
|
672
|
+
await assertCapturedHash(bytes, result.hash);
|
|
673
|
+
return {
|
|
674
|
+
path,
|
|
675
|
+
encoding,
|
|
676
|
+
content,
|
|
677
|
+
sizeBytes: result.sizeBytes,
|
|
678
|
+
truncated: false,
|
|
679
|
+
isBinary: result.isBinary,
|
|
680
|
+
revision: result.revision,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
throw new Error("Captured file download failed after refreshing its URL.");
|
|
684
|
+
}
|
|
685
|
+
return await client.fsRead(workspaceId, sessionId, { path }, { signal });
|
|
477
686
|
},
|
|
478
|
-
[client, workspaceId, sessionId],
|
|
687
|
+
[client, workspaceId, sessionId, capture, source],
|
|
479
688
|
);
|
|
480
689
|
|
|
481
690
|
// TARGETED reconcile of a single directory — re-list ONE parent at depth 1 and
|
|
@@ -489,13 +698,19 @@ export function useSandboxFiles(
|
|
|
489
698
|
async (path: string) => {
|
|
490
699
|
if (!sessionId) return;
|
|
491
700
|
const identityGeneration = identityGenerationRef.current;
|
|
701
|
+
const identitySignal = identityAbortRef.current.signal;
|
|
492
702
|
// Skip parents that aren't currently loaded/expanded in the tree (nothing
|
|
493
703
|
// visible to update; they re-list fresh on the next expand).
|
|
494
704
|
if (!parentIsLoaded(treeRef.current, path)) return;
|
|
495
705
|
try {
|
|
496
|
-
const listed = await client.fsList(
|
|
706
|
+
const listed = await client.fsList(
|
|
707
|
+
workspaceId,
|
|
708
|
+
sessionId,
|
|
709
|
+
{ path, depth: 1 },
|
|
710
|
+
{ signal: identitySignal },
|
|
711
|
+
);
|
|
497
712
|
if (identityGenerationRef.current !== identityGeneration) return;
|
|
498
|
-
const children = (listed.root.children ?? []).map(fsNodeToTree);
|
|
713
|
+
const children = (listed.root.children ?? []).map((node) => fsNodeToTree(node));
|
|
499
714
|
if (path === "") setTree((prev) => applyStatus(mergeRootChildren(prev, children)));
|
|
500
715
|
else
|
|
501
716
|
setTree((prev) => {
|
|
@@ -530,6 +745,7 @@ export function useSandboxFiles(
|
|
|
530
745
|
const identityGeneration = identityGenerationRef.current;
|
|
531
746
|
// Snapshot the pre-op tree from the ref (StrictMode-safe — see treeRef).
|
|
532
747
|
const snapshot = treeRef.current;
|
|
748
|
+
setError(null);
|
|
533
749
|
setTree(applyStatus(apply(snapshot)));
|
|
534
750
|
try {
|
|
535
751
|
const res = await op();
|
|
@@ -552,6 +768,11 @@ export function useSandboxFiles(
|
|
|
552
768
|
if (identityGenerationRef.current !== identityGeneration) throw err;
|
|
553
769
|
// Revert the optimistic edit to the exact pre-op tree.
|
|
554
770
|
setTree(applyStatus(snapshot));
|
|
771
|
+
// An expected-content conflict belongs to the editor's explicit
|
|
772
|
+
// Reload/Overwrite decision surface. Treating it as a broken file tree
|
|
773
|
+
// would also raise a duplicate host toast and leave a stale global error
|
|
774
|
+
// after the user successfully resolves the conflict.
|
|
775
|
+
if (err instanceof FileWriteConflictError) throw err;
|
|
555
776
|
setError(err);
|
|
556
777
|
onMutationError?.(err, opName);
|
|
557
778
|
throw err;
|
|
@@ -561,8 +782,14 @@ export function useSandboxFiles(
|
|
|
561
782
|
);
|
|
562
783
|
|
|
563
784
|
const writeFile = useCallback(
|
|
564
|
-
async (
|
|
785
|
+
async (
|
|
786
|
+
path: string,
|
|
787
|
+
content: string,
|
|
788
|
+
writeOptions: SandboxWriteFileOptions = {},
|
|
789
|
+
): Promise<FsWriteResponse> => {
|
|
565
790
|
if (!sessionId) throw new Error("no session");
|
|
791
|
+
const identityGeneration = identityGenerationRef.current;
|
|
792
|
+
const identitySignal = identityAbortRef.current.signal;
|
|
566
793
|
const parent = parentOf(path);
|
|
567
794
|
// Splice a new file node in immediately ONLY when the path doesn't already
|
|
568
795
|
// exist in a loaded parent (an editor SAVE to an existing file mutates no
|
|
@@ -572,7 +799,32 @@ export function useSandboxFiles(
|
|
|
572
799
|
return await runOptimistic(
|
|
573
800
|
"write",
|
|
574
801
|
(nodes) => (exists ? nodes : insertNode(nodes, parent, node)),
|
|
575
|
-
() =>
|
|
802
|
+
async () => {
|
|
803
|
+
if (!writeOptions.force && writeOptions.expectedContent !== undefined) {
|
|
804
|
+
const live = await client.fsRead(
|
|
805
|
+
workspaceId,
|
|
806
|
+
sessionId,
|
|
807
|
+
{ path },
|
|
808
|
+
{ signal: identitySignal },
|
|
809
|
+
);
|
|
810
|
+
if (identityGenerationRef.current !== identityGeneration) {
|
|
811
|
+
throw new Error("File save cancelled because the workspace changed.");
|
|
812
|
+
}
|
|
813
|
+
const liveContent =
|
|
814
|
+
live.encoding === "base64" ? decodeBase64Text(live.content) : live.content;
|
|
815
|
+
if (live.truncated || live.isBinary || liveContent !== writeOptions.expectedContent) {
|
|
816
|
+
throw new FileWriteConflictError(path, writeOptions.expectedContent, liveContent);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
if (identityGenerationRef.current !== identityGeneration) {
|
|
820
|
+
throw new Error("File save cancelled because the workspace changed.");
|
|
821
|
+
}
|
|
822
|
+
return await client.fsWrite(workspaceId, sessionId, {
|
|
823
|
+
path,
|
|
824
|
+
content,
|
|
825
|
+
overwrite: true,
|
|
826
|
+
});
|
|
827
|
+
},
|
|
576
828
|
exists ? [] : [parent],
|
|
577
829
|
);
|
|
578
830
|
},
|
|
@@ -650,30 +902,30 @@ export function useSandboxFiles(
|
|
|
650
902
|
[client, workspaceId, sessionId, runOptimistic],
|
|
651
903
|
);
|
|
652
904
|
|
|
653
|
-
// Initial paint + reset on identity change. Source selection
|
|
654
|
-
// •
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
// reconciles live in place).
|
|
905
|
+
// Initial paint + reset on identity change. Source selection:
|
|
906
|
+
// • any box WITH a capture → paint instantly from the durable capture index;
|
|
907
|
+
// a warm/draining box then reconciles live in place.
|
|
908
|
+
// • if that live reconciliation fails, keep the capture visible and read-only.
|
|
658
909
|
// • cold box with NO capture → best-effort live list (status quo — never worse).
|
|
659
|
-
const capture = options.capture ?? null;
|
|
660
|
-
const isLive = options.liveness === "warm" || options.liveness === "draining";
|
|
661
910
|
// Key the seed on the capture's REVISION (a primitive), not the manifest object
|
|
662
911
|
// — a consumer passing a fresh object each render (or a new revision) must not
|
|
663
912
|
// spin the effect. The latest manifest is read from a ref at run time.
|
|
664
913
|
const captureRef = useRef<WorkspaceCaptureManifest | null>(capture);
|
|
665
914
|
captureRef.current = capture;
|
|
666
915
|
const captureRevision = capture?.revision ?? null;
|
|
667
|
-
const identityKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${rootPath}`;
|
|
668
916
|
const previousIdentityRef = useRef(identityKey);
|
|
669
917
|
useEffect(() => {
|
|
670
918
|
// Any source-selection change supersedes an older root refresh. A true data
|
|
671
919
|
// identity change additionally fences all other async tree work and resets
|
|
672
920
|
// event/debounce state before the event-folding effect runs.
|
|
921
|
+
refreshAbortRef.current?.abort();
|
|
922
|
+
refreshAbortRef.current = null;
|
|
673
923
|
refreshGenerationRef.current += 1;
|
|
674
924
|
const identityChanged = previousIdentityRef.current !== identityKey;
|
|
675
925
|
previousIdentityRef.current = identityKey;
|
|
676
926
|
if (identityChanged || !enabled) {
|
|
927
|
+
identityAbortRef.current.abort();
|
|
928
|
+
identityAbortRef.current = new AbortController();
|
|
677
929
|
identityGenerationRef.current += 1;
|
|
678
930
|
lastSeqRef.current = 0;
|
|
679
931
|
pendingParentsRef.current = new Set();
|
|
@@ -686,7 +938,9 @@ export function useSandboxFiles(
|
|
|
686
938
|
statusRef.current = new Map();
|
|
687
939
|
treeRef.current = [];
|
|
688
940
|
setTree([]);
|
|
941
|
+
setStateIdentity(identityKey);
|
|
689
942
|
setExpandingPaths(new Set());
|
|
943
|
+
sourceRef.current = null;
|
|
690
944
|
setSource(null);
|
|
691
945
|
setLoading(false);
|
|
692
946
|
setError(null);
|
|
@@ -694,14 +948,16 @@ export function useSandboxFiles(
|
|
|
694
948
|
if (!enabled) {
|
|
695
949
|
return;
|
|
696
950
|
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
951
|
+
const currentCapture = captureRevision !== null ? captureRef.current : null;
|
|
952
|
+
if (currentCapture && (identityChanged || !isLive || sourceRef.current !== "live")) {
|
|
953
|
+
seedFromCapture(currentCapture);
|
|
954
|
+
}
|
|
955
|
+
if (isLive || !currentCapture) {
|
|
702
956
|
void refresh();
|
|
703
957
|
}
|
|
704
958
|
return () => {
|
|
959
|
+
refreshAbortRef.current?.abort();
|
|
960
|
+
refreshAbortRef.current = null;
|
|
705
961
|
refreshGenerationRef.current += 1;
|
|
706
962
|
};
|
|
707
963
|
}, [enabled, isLive, captureRevision, identityKey, refresh, seedFromCapture]);
|
|
@@ -712,8 +968,14 @@ export function useSandboxFiles(
|
|
|
712
968
|
const refreshGitOverlay = useCallback(async () => {
|
|
713
969
|
if (!sessionId) return;
|
|
714
970
|
const identityGeneration = identityGenerationRef.current;
|
|
971
|
+
const identitySignal = identityAbortRef.current.signal;
|
|
715
972
|
try {
|
|
716
|
-
const status = await client.gitStatus(
|
|
973
|
+
const status = await client.gitStatus(
|
|
974
|
+
workspaceId,
|
|
975
|
+
sessionId,
|
|
976
|
+
{ path: rootPath },
|
|
977
|
+
{ signal: identitySignal },
|
|
978
|
+
);
|
|
717
979
|
if (identityGenerationRef.current !== identityGeneration) return;
|
|
718
980
|
const overlay = new Map<string, FileTreeStatus>();
|
|
719
981
|
for (const file of status.files) {
|
|
@@ -802,33 +1064,19 @@ export function useSandboxFiles(
|
|
|
802
1064
|
|
|
803
1065
|
useEffect(
|
|
804
1066
|
() => () => {
|
|
1067
|
+
refreshAbortRef.current?.abort();
|
|
1068
|
+
identityAbortRef.current.abort();
|
|
805
1069
|
identityGenerationRef.current += 1;
|
|
806
1070
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
807
1071
|
},
|
|
808
1072
|
[],
|
|
809
1073
|
);
|
|
810
1074
|
|
|
811
|
-
|
|
812
|
-
// advertised on a cold box too, so the mount-time `refresh()` can run before
|
|
813
|
-
// the box is up (empty/errored result); without an `fs.changed` event the tree
|
|
814
|
-
// would stay empty forever. A cold->warm transition re-lists once the box is
|
|
815
|
-
// actually serving — the real fix for the "No files" the deployed app showed.
|
|
816
|
-
const wasLiveRef = useRef(false);
|
|
817
|
-
const liveness = options.liveness;
|
|
818
|
-
useEffect(() => {
|
|
819
|
-
const live = liveness === "warm" || liveness === "draining";
|
|
820
|
-
if (enabled && live && !wasLiveRef.current) {
|
|
821
|
-
wasLiveRef.current = true;
|
|
822
|
-
void refresh();
|
|
823
|
-
} else if (!live) {
|
|
824
|
-
wasLiveRef.current = false;
|
|
825
|
-
}
|
|
826
|
-
}, [enabled, liveness, refresh]);
|
|
827
|
-
|
|
1075
|
+
const identityMatches = enabled && stateIdentity === identityKey;
|
|
828
1076
|
return {
|
|
829
|
-
tree,
|
|
1077
|
+
tree: identityMatches ? tree : [],
|
|
830
1078
|
expand,
|
|
831
|
-
expandingPaths,
|
|
1079
|
+
expandingPaths: identityMatches ? expandingPaths : new Set<string>(),
|
|
832
1080
|
readFile,
|
|
833
1081
|
writeFile,
|
|
834
1082
|
createFile,
|
|
@@ -836,9 +1084,9 @@ export function useSandboxFiles(
|
|
|
836
1084
|
deleteEntry,
|
|
837
1085
|
moveEntry,
|
|
838
1086
|
refresh,
|
|
839
|
-
source,
|
|
840
|
-
capturedAt: source === "capture" ? (capture?.capturedAt ?? null) : null,
|
|
841
|
-
loading,
|
|
842
|
-
error,
|
|
1087
|
+
source: identityMatches ? source : null,
|
|
1088
|
+
capturedAt: identityMatches && source === "capture" ? (capture?.capturedAt ?? null) : null,
|
|
1089
|
+
loading: identityMatches && loading,
|
|
1090
|
+
error: identityMatches ? error : null,
|
|
843
1091
|
};
|
|
844
1092
|
}
|