@opengeni/react 0.15.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +110 -10
  2. package/dist/{chunk-TOJR776I.js → chunk-6BXBGJ3B.js} +45 -369
  3. package/dist/chunk-6BXBGJ3B.js.map +1 -0
  4. package/dist/chunk-6UT5OC3P.js +2133 -0
  5. package/dist/chunk-6UT5OC3P.js.map +1 -0
  6. package/dist/chunk-EBTT3MYN.js +92 -0
  7. package/dist/chunk-EBTT3MYN.js.map +1 -0
  8. package/dist/chunk-HHFA4AFX.js +425 -0
  9. package/dist/chunk-HHFA4AFX.js.map +1 -0
  10. package/dist/chunk-LAZ2A743.js +2393 -0
  11. package/dist/chunk-LAZ2A743.js.map +1 -0
  12. package/dist/chunk-MRSECXRP.js +563 -0
  13. package/dist/chunk-MRSECXRP.js.map +1 -0
  14. package/dist/chunk-TK7G6XLT.js +18 -0
  15. package/dist/chunk-TK7G6XLT.js.map +1 -0
  16. package/dist/composer-DvUaa5ki.d.ts +585 -0
  17. package/dist/composer.d.ts +6 -0
  18. package/dist/composer.js +56 -0
  19. package/dist/composer.js.map +1 -0
  20. package/dist/index.d.ts +1288 -1785
  21. package/dist/index.js +7015 -8806
  22. package/dist/index.js.map +1 -1
  23. package/dist/machines.d.ts +412 -3
  24. package/dist/machines.js +3 -1
  25. package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
  26. package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
  27. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
  28. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
  29. package/dist/queue-surface-implementation-NQMV2ML3.js +890 -0
  30. package/dist/queue-surface-implementation-NQMV2ML3.js.map +1 -0
  31. package/dist/session-Gd4iVPYw.d.ts +425 -0
  32. package/dist/session-context-DIFFlXKc.d.ts +52 -0
  33. package/dist/session.d.ts +4 -0
  34. package/dist/session.js +50 -0
  35. package/dist/session.js.map +1 -0
  36. package/dist/use-composer-BCfm4mzX.d.ts +112 -0
  37. package/package.json +20 -4
  38. package/src/approvals.ts +5 -3
  39. package/src/client.ts +40 -0
  40. package/src/components/approval-surface.tsx +198 -0
  41. package/src/components/chat-composer.tsx +94 -993
  42. package/src/components/code-editor.tsx +104 -26
  43. package/src/components/command-palette.tsx +8 -2
  44. package/src/components/composer-transcription-control.tsx +211 -0
  45. package/src/components/composer.tsx +1536 -0
  46. package/src/components/desktop-viewer.tsx +3 -3
  47. package/src/components/enrollment-consent.tsx +2 -2
  48. package/src/components/file-browser.tsx +367 -29
  49. package/src/components/human-input-form.tsx +417 -0
  50. package/src/components/machine-card.tsx +1 -1
  51. package/src/components/machines-dashboard.tsx +3 -3
  52. package/src/components/message-timeline.tsx +15 -7
  53. package/src/components/model-picker.tsx +12 -3
  54. package/src/components/pierre-diff.tsx +32 -6
  55. package/src/components/queue-surface-implementation.tsx +1041 -0
  56. package/src/components/queue-surface-state.tsx +94 -0
  57. package/src/components/queue-surface.tsx +41 -559
  58. package/src/components/sandbox-files.tsx +138 -14
  59. package/src/components/sandbox-terminal.tsx +8 -2
  60. package/src/components/sandbox-workspace.tsx +339 -119
  61. package/src/components/workbench-changes.tsx +84 -33
  62. package/src/components/workspace-dock.tsx +188 -18
  63. package/src/composer.ts +60 -0
  64. package/src/hooks/internal.ts +115 -34
  65. package/src/hooks/use-composer.ts +335 -104
  66. package/src/hooks/use-human-input.ts +131 -0
  67. package/src/hooks/use-machine-chip.ts +2 -2
  68. package/src/hooks/use-machines.ts +27 -11
  69. package/src/hooks/use-sandbox-files.ts +310 -62
  70. package/src/hooks/use-sandbox-git.ts +154 -40
  71. package/src/hooks/use-sandbox-terminal.ts +28 -6
  72. package/src/hooks/use-session-capabilities.ts +38 -9
  73. package/src/hooks/use-session-control.ts +37 -8
  74. package/src/hooks/use-session-events.ts +522 -48
  75. package/src/hooks/use-slash-commands.ts +38 -38
  76. package/src/hooks/use-transcription.ts +757 -0
  77. package/src/hooks/use-turn-queue.ts +134 -66
  78. package/src/hooks/use-windowed-sections.ts +2 -2
  79. package/src/hooks/use-workspace-capture.ts +146 -40
  80. package/src/hooks/use-workspace-edit.ts +50 -14
  81. package/src/hooks/use-workspace-sessions.ts +3 -0
  82. package/src/human-input.ts +72 -0
  83. package/src/index.ts +76 -6
  84. package/src/lib/noto-sans-arabic-loader.ts +21 -0
  85. package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
  86. package/src/lib/use-portal-token-style.ts +51 -0
  87. package/src/lib/use-unicode-fonts.ts +57 -0
  88. package/src/provider.tsx +4 -69
  89. package/src/session-context.ts +135 -0
  90. package/src/session.ts +79 -0
  91. package/src/timeline/parsers.ts +35 -0
  92. package/src/timeline/projection.ts +12 -4
  93. package/src/timeline/tool-renderers.tsx +41 -0
  94. package/src/timeline/turn-summary.tsx +2 -2
  95. package/src/timeline/types.ts +16 -8
  96. package/styles/index.css +56 -0
  97. package/styles/index.d.ts +2 -0
  98. package/styles/tokens.css +11 -6
  99. package/styles/tokens.d.ts +2 -0
  100. package/dist/chunk-TOJR776I.js.map +0 -1
  101. 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`). When the
47
- * box is NOT warm, the tree paints INSTANTLY from this capture's tree index (the
48
- * <200ms cold first paint) instead of blocking on a Channel-A list. A warm box
49
- * always wins (live path unchanged). On the cold→warm transition the live list
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: (path: string, content: string) => Promise<FsWriteResponse>;
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 ? node.children.map(fsNodeToTree) : undefined;
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 (dossier §3 #6 / §12-D1). An empty overlay is still
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(workspaceId, sessionId, { path: rootPath });
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(workspaceId, sessionId, { path: rootPath, depth: 1 });
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). Never runs while warm.
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
- const children = (manifest.treeIndex.children ?? []).map(fsNodeToTree);
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(workspaceId, sessionId, { path, depth: 1 });
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
- return await client.fsRead(workspaceId, sessionId, { path });
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(workspaceId, sessionId, { path, depth: 1 });
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 (path: string, content: string): Promise<FsWriteResponse> => {
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
- () => client.fsWrite(workspaceId, sessionId, { path, content, overwrite: true }),
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 (dossier §10.4):
654
- // • warm/draining box → the LIVE list (unchanged behavior).
655
- // cold/offline box WITH a capture paint instantly from the capture index
656
- // (no Channel-A; the box warms in the background and the warm effect below
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
- if (isLive) {
698
- void refresh();
699
- } else if (captureRevision !== null && captureRef.current) {
700
- seedFromCapture(captureRef.current);
701
- } else {
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(workspaceId, sessionId, { path: rootPath });
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
- // Re-list when the box first becomes warm. The FileSystem capability is
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
  }