@opengeni/react 1.0.1 → 2.2.0-canary.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 (174) hide show
  1. package/README.md +33 -10
  2. package/dist/artifacts-spreadsheet.js +1 -1
  3. package/dist/artifacts.js +1 -1
  4. package/dist/browser-viewer-GXUICIAG.js +14 -0
  5. package/dist/{chunk-R3AZOAB5.js → chunk-4MDLEHXL.js} +9 -115
  6. package/dist/chunk-4MDLEHXL.js.map +1 -0
  7. package/dist/{chunk-Y7OEDUJ5.js → chunk-5RZN3DD5.js} +1014 -420
  8. package/dist/chunk-5RZN3DD5.js.map +1 -0
  9. package/dist/chunk-5YK2ZDIG.js +116 -0
  10. package/dist/chunk-5YK2ZDIG.js.map +1 -0
  11. package/dist/chunk-H5YGYNXS.js +329 -0
  12. package/dist/chunk-H5YGYNXS.js.map +1 -0
  13. package/dist/{chunk-7WCLAEFV.js → chunk-ICA6R6TW.js} +2 -2
  14. package/dist/{chunk-7WCLAEFV.js.map → chunk-ICA6R6TW.js.map} +1 -1
  15. package/dist/{chunk-IGC6A2ZI.js → chunk-JOAVA2TW.js} +589 -217
  16. package/dist/chunk-JOAVA2TW.js.map +1 -0
  17. package/dist/{chunk-5UQPNLIO.js → chunk-LTCHJ4MS.js} +9 -9
  18. package/dist/chunk-LTCHJ4MS.js.map +1 -0
  19. package/dist/{chunk-C5ZPSSS5.js → chunk-NEXLLTP4.js} +2 -1
  20. package/dist/{chunk-C5ZPSSS5.js.map → chunk-NEXLLTP4.js.map} +1 -1
  21. package/dist/chunk-QGBJ6GHG.js +2300 -0
  22. package/dist/chunk-QGBJ6GHG.js.map +1 -0
  23. package/dist/{chunk-B6WPZSTT.js → chunk-UOLJWT5Y.js} +78 -16
  24. package/dist/chunk-UOLJWT5Y.js.map +1 -0
  25. package/dist/{chunk-MKPIAGVL.js → chunk-W6NFJXSA.js} +8 -19
  26. package/dist/chunk-W6NFJXSA.js.map +1 -0
  27. package/dist/{chunk-XBZWOM2K.js → chunk-X4A66EWZ.js} +8 -3
  28. package/dist/chunk-X4A66EWZ.js.map +1 -0
  29. package/dist/chunk-X4JAFD5R.js +3907 -0
  30. package/dist/chunk-X4JAFD5R.js.map +1 -0
  31. package/dist/{chunk-6KLX2RH5.js → chunk-XSA2QAKP.js} +334 -19
  32. package/dist/chunk-XSA2QAKP.js.map +1 -0
  33. package/dist/{chunk-HA46CYQK.js → chunk-XXWH2JLC.js} +792 -206
  34. package/dist/chunk-XXWH2JLC.js.map +1 -0
  35. package/dist/client.d.ts +2 -2
  36. package/dist/components/browser-viewer.d.ts +7 -1
  37. package/dist/components/computer-viewer.d.ts +4 -1
  38. package/dist/components/enrollment-consent.d.ts +6 -3
  39. package/dist/components/file-browser.d.ts +7 -1
  40. package/dist/components/human-input-form.d.ts +1 -1
  41. package/dist/components/human-input-surface.d.ts +1 -1
  42. package/dist/components/machine-card.d.ts +4 -1
  43. package/dist/components/machines/machine-detail.d.ts +4 -1
  44. package/dist/components/machines/machine-operation-policy.d.ts +11 -0
  45. package/dist/components/machines-dashboard.d.ts +3 -1
  46. package/dist/components/markdown.d.ts +20 -1
  47. package/dist/components/message-timeline.d.ts +1 -1
  48. package/dist/components/model-policy-picker.d.ts +2 -0
  49. package/dist/components/pierre-file.d.ts +1 -1
  50. package/dist/components/sandbox-files.d.ts +8 -2
  51. package/dist/components/sandbox-workspace.d.ts +33 -3
  52. package/dist/components/session-chrome.d.ts +15 -3
  53. package/dist/components/workspace-dock.d.ts +6 -2
  54. package/dist/composer.js +5 -5
  55. package/dist/computer-viewer-ZOP4Q7A7.js +16 -0
  56. package/dist/computer-viewer-ZOP4Q7A7.js.map +1 -0
  57. package/dist/hooks/channel-cache.d.ts +23 -0
  58. package/dist/hooks/shared-machines-list.d.ts +14 -0
  59. package/dist/hooks/use-attached-browsers.d.ts +2 -1
  60. package/dist/hooks/use-channels.d.ts +1 -0
  61. package/dist/hooks/use-composer.d.ts +37 -9
  62. package/dist/hooks/use-machine-chip.d.ts +3 -3
  63. package/dist/hooks/use-machines.d.ts +19 -1
  64. package/dist/hooks/use-sandbox-files.d.ts +1 -0
  65. package/dist/hooks/use-session-background-commands.d.ts +14 -0
  66. package/dist/hooks/use-turn-queue.d.ts +12 -0
  67. package/dist/hooks/use-workspace-sessions.d.ts +1 -0
  68. package/dist/index.d.ts +7 -5
  69. package/dist/index.js +1096 -623
  70. package/dist/index.js.map +1 -1
  71. package/dist/interaction.js +29 -181
  72. package/dist/interaction.js.map +1 -1
  73. package/dist/lib/interaction-errors.d.ts +3 -0
  74. package/dist/machines.d.ts +4 -2
  75. package/dist/machines.js +10 -2
  76. package/dist/model-policy.d.ts +1 -1
  77. package/dist/model-policy.js +1 -1
  78. package/dist/platform-activity-row-BJHUHXAT.js +169 -0
  79. package/dist/platform-activity-row-BJHUHXAT.js.map +1 -0
  80. package/dist/{queue-surface-implementation-2KVJTYXH.js → queue-surface-implementation-BSKUWHRI.js} +76 -16
  81. package/dist/queue-surface-implementation-BSKUWHRI.js.map +1 -0
  82. package/dist/realtime/realtime-control.d.ts +6 -3
  83. package/dist/realtime.js +36 -14
  84. package/dist/realtime.js.map +1 -1
  85. package/dist/session-ui.d.ts +1 -1
  86. package/dist/session-ui.js +6 -2
  87. package/dist/session.d.ts +2 -2
  88. package/dist/session.js +4 -4
  89. package/dist/timeline/index.d.ts +1 -1
  90. package/dist/timeline/platform-activity-row.d.ts +15 -0
  91. package/dist/timeline/screenshot-lightbox.d.ts +1 -1
  92. package/dist/timeline/shared.d.ts +4 -2
  93. package/dist/timeline/types.d.ts +48 -5
  94. package/dist/types/machines.d.ts +2 -2
  95. package/package.json +3 -3
  96. package/src/client.ts +12 -1
  97. package/src/components/artifacts/editable-spreadsheet.tsx +1 -1
  98. package/src/components/browser-viewer.tsx +361 -58
  99. package/src/components/chat-composer.tsx +1 -1
  100. package/src/components/composer.tsx +6 -6
  101. package/src/components/computer-viewer.tsx +368 -115
  102. package/src/components/enrollment-consent.tsx +64 -6
  103. package/src/components/file-browser.tsx +117 -2
  104. package/src/components/human-input-form.tsx +91 -55
  105. package/src/components/human-input-surface.tsx +1 -1
  106. package/src/components/machine-card.tsx +111 -0
  107. package/src/components/machine-input-display.ts +15 -0
  108. package/src/components/machines/machine-detail.tsx +47 -0
  109. package/src/components/machines/machine-operation-policy.tsx +179 -0
  110. package/src/components/machines-dashboard.tsx +6 -0
  111. package/src/components/markdown.tsx +174 -20
  112. package/src/components/message-timeline.tsx +162 -17
  113. package/src/components/model-policy-picker.tsx +9 -17
  114. package/src/components/pierre-file.tsx +1 -1
  115. package/src/components/queue-surface-implementation.tsx +82 -16
  116. package/src/components/sandbox-files.tsx +135 -19
  117. package/src/components/sandbox-workspace.tsx +204 -92
  118. package/src/components/session-chrome.tsx +415 -208
  119. package/src/components/session-status.tsx +1 -1
  120. package/src/components/user-message-body.tsx +168 -48
  121. package/src/components/workspace-dock.tsx +141 -175
  122. package/src/hooks/channel-cache.ts +104 -0
  123. package/src/hooks/shared-machines-list.ts +239 -0
  124. package/src/hooks/use-attached-browsers.ts +5 -1
  125. package/src/hooks/use-browser-frame-stream.ts +6 -1
  126. package/src/hooks/use-browser-session.ts +6 -4
  127. package/src/hooks/use-browser-sessions.ts +7 -1
  128. package/src/hooks/use-channels.ts +32 -10
  129. package/src/hooks/use-composer.ts +760 -291
  130. package/src/hooks/use-computer-frame-stream.ts +14 -9
  131. package/src/hooks/use-computer-session.ts +16 -14
  132. package/src/hooks/use-computer-sessions.ts +8 -1
  133. package/src/hooks/use-last-started-turn-policy.ts +1 -1
  134. package/src/hooks/use-machine-chip.ts +23 -6
  135. package/src/hooks/use-machines.ts +104 -6
  136. package/src/hooks/use-sandbox-files.ts +195 -140
  137. package/src/hooks/use-sandbox-git.ts +58 -29
  138. package/src/hooks/use-session-background-commands.ts +56 -0
  139. package/src/hooks/use-session-capabilities.ts +18 -0
  140. package/src/hooks/use-turn-queue.ts +111 -12
  141. package/src/hooks/use-workspace-sessions.ts +5 -1
  142. package/src/index.ts +26 -4
  143. package/src/lib/interaction-errors.ts +18 -0
  144. package/src/machines.ts +9 -1
  145. package/src/model-policy.ts +10 -2
  146. package/src/realtime/realtime-control.tsx +53 -17
  147. package/src/session-context.ts +1 -0
  148. package/src/session-ui.ts +6 -1
  149. package/src/session.ts +9 -1
  150. package/src/timeline/activity-rail.tsx +20 -75
  151. package/src/timeline/index.ts +4 -0
  152. package/src/timeline/platform-activity-row.tsx +201 -0
  153. package/src/timeline/projection.ts +476 -23
  154. package/src/timeline/screenshot-lightbox.tsx +48 -14
  155. package/src/timeline/shared.tsx +8 -2
  156. package/src/timeline/tool-renderers.tsx +104 -6
  157. package/src/timeline/turn-summary.tsx +6 -1
  158. package/src/timeline/types.ts +75 -2
  159. package/src/types/machines.ts +10 -0
  160. package/styles/compiled.css +115 -104
  161. package/dist/chunk-5UQPNLIO.js.map +0 -1
  162. package/dist/chunk-6KLX2RH5.js.map +0 -1
  163. package/dist/chunk-B6WPZSTT.js.map +0 -1
  164. package/dist/chunk-HA46CYQK.js.map +0 -1
  165. package/dist/chunk-IGC6A2ZI.js.map +0 -1
  166. package/dist/chunk-LLOLUHVG.js +0 -5937
  167. package/dist/chunk-LLOLUHVG.js.map +0 -1
  168. package/dist/chunk-MKPIAGVL.js.map +0 -1
  169. package/dist/chunk-R3AZOAB5.js.map +0 -1
  170. package/dist/chunk-XBZWOM2K.js.map +0 -1
  171. package/dist/chunk-Y7OEDUJ5.js.map +0 -1
  172. package/dist/interactive-workbench-viewers-TOHBXJEE.js +0 -15
  173. package/dist/queue-surface-implementation-2KVJTYXH.js.map +0 -1
  174. /package/dist/{interactive-workbench-viewers-TOHBXJEE.js.map → browser-viewer-GXUICIAG.js.map} +0 -0
@@ -65,7 +65,8 @@ export type SandboxWriteFileOptions = {
65
65
  /** A node in the Pierre file tree. `children === undefined` ⇒ an unexpanded dir
66
66
  * (lazy treeMode); `children: []` ⇒ an expanded-but-empty dir. */
67
67
  export type FileTreeNode = {
68
- path: string; // workspace-relative POSIX
68
+ /** Opaque path returned by the selected target's FileSystem boundary. */
69
+ path: string;
69
70
  name: string;
70
71
  kind: "file" | "dir";
71
72
  children?: FileTreeNode[] | undefined;
@@ -162,16 +163,21 @@ export type SandboxFilesGitSummary = {
162
163
  };
163
164
 
164
165
  function normalizeRoot(root: string): string {
165
- const normalized = root
166
- .replaceAll("\\", "/")
167
- .replace(/^\.\/+/, "")
168
- .replace(/\/+$/, "");
166
+ const slashNormalized = root.replaceAll("\\", "/").replace(/^\.\/+/, "");
167
+ if (slashNormalized === "/") return "/";
168
+ const normalized = slashNormalized.replace(/\/+$/, "");
169
169
  return normalized === "." ? "" : normalized;
170
170
  }
171
171
 
172
- function qualifyStatusPath(root: string, path: string): string {
172
+ function joinTreeRoot(root: string, path: string): string {
173
+ if (!root || path.startsWith("/")) return path;
174
+ return root === "/" ? `/${path}` : `${root}/${path}`;
175
+ }
176
+
177
+ function qualifyStatusPath(root: string, path: string, treeRoot: string): string {
173
178
  const normalized = normalizeRoot(root);
174
- return normalized ? `${normalized}/${path}` : path;
179
+ const workspaceRelative = joinTreeRoot(normalized, path);
180
+ return joinTreeRoot(normalizeRoot(treeRoot), workspaceRelative);
175
181
  }
176
182
 
177
183
  function summarizeStatuses(
@@ -194,6 +200,11 @@ function parentOf(path: string): string {
194
200
  return i <= 0 ? "" : path.slice(0, i);
195
201
  }
196
202
 
203
+ function parentWithinTree(path: string, rootPath: string): string {
204
+ const parent = parentOf(path);
205
+ return parent === "" && normalizeRoot(rootPath) === "/" && path.startsWith("/") ? "/" : parent;
206
+ }
207
+
197
208
  /** The leaf name of a POSIX path. */
198
209
  function leafOf(path: string): string {
199
210
  const i = path.lastIndexOf("/");
@@ -282,33 +293,22 @@ function findNodeByPath(nodes: FileTreeNode[], path: string): FileTreeNode | und
282
293
  return undefined;
283
294
  }
284
295
 
285
- /** Root plus every directory whose children are currently loaded. Re-listing
286
- * this small visible frontier refreshes agent shell edits without expanding or
287
- * traversing hidden directories. */
288
- function loadedDirectoryPaths(nodes: FileTreeNode[]): string[] {
289
- const paths = [""];
290
- const walk = (list: FileTreeNode[]) => {
291
- for (const node of list) {
292
- if (node.kind !== "dir" || node.children === undefined) continue;
293
- paths.push(node.path);
294
- walk(node.children);
295
- }
296
- };
297
- walk(nodes);
298
- return paths;
299
- }
296
+ const FS_LIST_BATCH_REQUEST_LIMIT = 16;
300
297
 
301
298
  /** Repository paths are capability-advertised and therefore safe to use as a
302
299
  * bounded initial lazy-tree frontier. Listing their ancestors concurrently with
303
300
  * the root avoids a root → repositories → repo remote waterfall. */
304
301
  function initialDirectoryFrontier(rootPath: string, repoPaths: readonly string[]): string[] {
305
- const rootParts = normalizeRoot(rootPath).split("/").filter(Boolean);
302
+ const root = normalizeRoot(rootPath);
303
+ const rootParts = root.split("/").filter(Boolean);
306
304
  const paths = new Set<string>();
307
305
  for (const repoPath of repoPaths) {
308
- const parts = normalizeRoot(repoPath).split("/").filter(Boolean);
306
+ const canonicalRepoPath = joinTreeRoot(root, normalizeRoot(repoPath));
307
+ const parts = canonicalRepoPath.split("/").filter(Boolean);
309
308
  if (rootParts.some((part, index) => parts[index] !== part)) continue;
310
309
  for (let depth = rootParts.length + 1; depth <= parts.length; depth += 1) {
311
- paths.add(parts.slice(0, depth).join("/"));
310
+ const path = parts.slice(0, depth).join("/");
311
+ paths.add(root.startsWith("/") ? `/${path}` : path);
312
312
  }
313
313
  }
314
314
  // A workspace with hundreds of repositories should remain lazy. This still
@@ -333,8 +333,8 @@ function initialDirectoryFrontier(rootPath: string, repoPaths: readonly string[]
333
333
 
334
334
  /** True when `parent` is the root ("") or a dir node that is actually present in
335
335
  * the loaded tree (so an insert/remove there would be visible). */
336
- function parentIsLoaded(nodes: FileTreeNode[], parent: string): boolean {
337
- if (parent === "") return true;
336
+ function parentIsLoaded(nodes: FileTreeNode[], parent: string, rootPath = ""): boolean {
337
+ if (parent === normalizeRoot(rootPath)) return true;
338
338
  const node = findNodeByPath(nodes, parent);
339
339
  return Boolean(node && node.kind === "dir" && node.children !== undefined);
340
340
  }
@@ -342,8 +342,13 @@ function parentIsLoaded(nodes: FileTreeNode[], parent: string): boolean {
342
342
  /** Insert `child` under `parent` (immutably), keeping siblings sorted. A no-op
343
343
  * (returns the same array) when the parent isn't a loaded dir or the child
344
344
  * already exists. */
345
- function insertNode(nodes: FileTreeNode[], parent: string, child: FileTreeNode): FileTreeNode[] {
346
- if (parent === "") {
345
+ function insertNode(
346
+ nodes: FileTreeNode[],
347
+ parent: string,
348
+ child: FileTreeNode,
349
+ rootPath = "",
350
+ ): FileTreeNode[] {
351
+ if (parent === normalizeRoot(rootPath)) {
347
352
  if (nodes.some((n) => n.path === child.path)) return nodes;
348
353
  return sortNodes([...nodes, child]);
349
354
  }
@@ -354,16 +359,16 @@ function insertNode(nodes: FileTreeNode[], parent: string, child: FileTreeNode):
354
359
  return { ...node, children: sortNodes([...node.children, child]) };
355
360
  }
356
361
  if (node.kind === "dir" && node.children && parent.startsWith(`${node.path}/`)) {
357
- return { ...node, children: insertNode(node.children, parent, child) };
362
+ return { ...node, children: insertNode(node.children, parent, child, rootPath) };
358
363
  }
359
364
  return node;
360
365
  });
361
366
  }
362
367
 
363
368
  /** Remove the node at `path` (immutably). No-op when it isn't in the tree. */
364
- function removeNode(nodes: FileTreeNode[], path: string): FileTreeNode[] {
365
- const parent = parentOf(path);
366
- if (parent === "") {
369
+ function removeNode(nodes: FileTreeNode[], path: string, rootPath = ""): FileTreeNode[] {
370
+ const parent = parentWithinTree(path, rootPath);
371
+ if (parent === normalizeRoot(rootPath)) {
367
372
  if (!nodes.some((n) => n.path === path)) return nodes;
368
373
  return nodes.filter((n) => n.path !== path);
369
374
  }
@@ -376,7 +381,7 @@ function removeNode(nodes: FileTreeNode[], path: string): FileTreeNode[] {
376
381
  };
377
382
  }
378
383
  if (node.kind === "dir" && node.children && parent.startsWith(`${node.path}/`)) {
379
- return { ...node, children: removeNode(node.children, path) };
384
+ return { ...node, children: removeNode(node.children, path, rootPath) };
380
385
  }
381
386
  return node;
382
387
  });
@@ -527,9 +532,9 @@ export function useSandboxFiles(
527
532
  const lastSeqRef = useRef(0);
528
533
  const pendingParentsRef = useRef<Set<string>>(new Set());
529
534
  const pendingGitRef = useRef(false);
530
- const pendingAgentToolRef = useRef(false);
535
+ const eventReconcileInFlightRef = useRef<Promise<void> | null>(null);
536
+ const eventReconcilePendingRef = useRef(false);
531
537
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
532
- const lastCommandRefreshAtRef = useRef(0);
533
538
 
534
539
  // ── Self-emitted fs.changed de-dupe ───────────────────────────────────────
535
540
  // Every one of OUR mutations emits an `fs.changed` event back on the live log
@@ -584,34 +589,15 @@ export function useSandboxFiles(
584
589
  setGitLoading(true);
585
590
  setError(null);
586
591
  try {
587
- // Start the optional Git tint and the actual directory listing together.
588
- // A remote status read must never block the first useful file-tree paint.
592
+ // Paint the useful file tree before the optional Git tint. Separate remote
593
+ // requests for one Modal box are deliberately serialized by the API; firing
594
+ // both here only made the Git request contend with the file request during
595
+ // cold starts.
589
596
  const frontierPaths = initialDirectoryFrontier(rootPath, repoPaths);
590
597
  const listRequests = [rootPath, ...frontierPaths].map((path) => ({
591
598
  path,
592
599
  depth: 1,
593
600
  }));
594
- const statusPromise =
595
- repoPaths.length > 0
596
- ? client
597
- .gitReadBatch(
598
- workspaceId,
599
- sessionId,
600
- {
601
- requests: repoPaths.map((root) => ({
602
- status: { path: root },
603
- })),
604
- },
605
- { signal: refreshAbort.signal },
606
- )
607
- .then((batch) =>
608
- batch.results.map((result, index) => ({
609
- root: repoPaths[index] ?? "",
610
- status: result.status,
611
- })),
612
- )
613
- .catch(() => null)
614
- : Promise.resolve([]);
615
601
  // Root plus the bounded repository frontier share one API request, one
616
602
  // direct holder, and one provider attach. The old per-directory fan-out
617
603
  // paid that fixed control-plane cost up to nine times on every Files open.
@@ -644,7 +630,27 @@ export function useSandboxFiles(
644
630
  setSource("live");
645
631
  setLoading(false);
646
632
 
647
- const statuses = await statusPromise;
633
+ const statuses =
634
+ repoPaths.length > 0
635
+ ? await client
636
+ .gitReadBatch(
637
+ workspaceId,
638
+ sessionId,
639
+ {
640
+ requests: repoPaths.map((root) => ({
641
+ status: { path: root },
642
+ })),
643
+ },
644
+ { signal: refreshAbort.signal },
645
+ )
646
+ .then((batch) =>
647
+ batch.results.map((result, index) => ({
648
+ root: repoPaths[index] ?? "",
649
+ status: result.status,
650
+ })),
651
+ )
652
+ .catch(() => null)
653
+ : [];
648
654
  if (refreshGenerationRef.current !== generation) return;
649
655
  if (statuses) {
650
656
  const overlay = new Map<string, FileTreeStatus>();
@@ -652,7 +658,7 @@ export function useSandboxFiles(
652
658
  for (const file of status.files) {
653
659
  const code = file.worktree ?? file.index;
654
660
  const mapped = code ? GIT_STATUS_TO_TREE[code] : undefined;
655
- if (mapped) overlay.set(qualifyStatusPath(root, file.path), mapped);
661
+ if (mapped) overlay.set(qualifyStatusPath(root, file.path, rootPath), mapped);
656
662
  }
657
663
  }
658
664
  statusRef.current = overlay;
@@ -866,13 +872,14 @@ export function useSandboxFiles(
866
872
  : identitySignal;
867
873
  // Skip parents that aren't currently loaded/expanded in the tree (nothing
868
874
  // visible to update; they re-list fresh on the next expand).
869
- if (!parentIsLoaded(treeRef.current, path)) return;
875
+ if (!parentIsLoaded(treeRef.current, path, rootPath)) return;
870
876
  try {
871
877
  const listed = await client.fsList(workspaceId, sessionId, { path, depth: 1 }, { signal });
872
878
  if (identityGenerationRef.current !== identityGeneration) return;
873
879
  const children = (listed.root.children ?? []).map((node) => fsNodeToTree(node));
874
- if (path === "") setTree((prev) => applyStatus(mergeRootChildren(prev, children)));
875
- else
880
+ if (path === normalizeRoot(rootPath)) {
881
+ setTree((prev) => applyStatus(mergeRootChildren(prev, children)));
882
+ } else
876
883
  setTree((prev) => {
877
884
  const existing = findNodeByPath(prev, path);
878
885
  const merged = existing?.children
@@ -885,7 +892,58 @@ export function useSandboxFiles(
885
892
  // root-refresh here (that would collapse the tree the user is working in).
886
893
  }
887
894
  },
888
- [client, workspaceId, sessionId, applyStatus],
895
+ [client, workspaceId, sessionId, rootPath, applyStatus],
896
+ );
897
+
898
+ // Reconcile the visible directory frontier through bounded batch requests.
899
+ // One tool completion can affect several expanded directories; issuing one
900
+ // provider request per directory created a self-inflicted lock convoy.
901
+ const reconcilePaths = useCallback(
902
+ async (requestedPaths: readonly string[], requestSignal?: AbortSignal) => {
903
+ if (!sessionId) return;
904
+ const identityGeneration = identityGenerationRef.current;
905
+ const identitySignal = identityAbortRef.current.signal;
906
+ const signal = requestSignal
907
+ ? AbortSignal.any([identitySignal, requestSignal])
908
+ : identitySignal;
909
+ const paths = [...new Set(requestedPaths)].filter((path) =>
910
+ parentIsLoaded(treeRef.current, path, rootPath),
911
+ );
912
+ for (let offset = 0; offset < paths.length; offset += FS_LIST_BATCH_REQUEST_LIMIT) {
913
+ const chunk = paths.slice(offset, offset + FS_LIST_BATCH_REQUEST_LIMIT);
914
+ try {
915
+ const listed = await client.fsListBatch(
916
+ workspaceId,
917
+ sessionId,
918
+ { requests: chunk.map((path) => ({ path, depth: 1 })) },
919
+ { signal },
920
+ );
921
+ if (identityGenerationRef.current !== identityGeneration) return;
922
+ setTree((previous) => {
923
+ let next = previous;
924
+ for (const [index, path] of chunk.entries()) {
925
+ const result = listed.results[index];
926
+ if (!result) continue;
927
+ const children = (result.root.children ?? []).map((node) => fsNodeToTree(node));
928
+ if (path === normalizeRoot(rootPath)) {
929
+ next = mergeRootChildren(next, children);
930
+ } else {
931
+ const existing = findNodeByPath(next, path);
932
+ const merged = existing?.children
933
+ ? mergeChildren(existing.children, children)
934
+ : children;
935
+ next = replaceChildren(next, path, merged);
936
+ }
937
+ }
938
+ return applyStatus(next);
939
+ });
940
+ } catch {
941
+ // Reconciliation is best-effort. Preserve the current visible tree and
942
+ // let the next real event or explicit refresh reconcile it.
943
+ }
944
+ }
945
+ },
946
+ [client, workspaceId, sessionId, rootPath, applyStatus],
889
947
  );
890
948
 
891
949
  // Run a Channel-A op behind an OPTIMISTIC tree edit. `apply` splices the change
@@ -950,7 +1008,7 @@ export function useSandboxFiles(
950
1008
  if (!sessionId) throw new Error("no session");
951
1009
  const identityGeneration = identityGenerationRef.current;
952
1010
  const identitySignal = identityAbortRef.current.signal;
953
- const parent = parentOf(path);
1011
+ const parent = parentWithinTree(path, rootPath);
954
1012
  // Splice a new file node in immediately ONLY when the path doesn't already
955
1013
  // exist in a loaded parent (an editor SAVE to an existing file mutates no
956
1014
  // tree shape — just content — so it needs no optimistic node, no reconcile).
@@ -963,7 +1021,7 @@ export function useSandboxFiles(
963
1021
  };
964
1022
  return await runOptimistic(
965
1023
  "write",
966
- (nodes) => (exists ? nodes : insertNode(nodes, parent, node)),
1024
+ (nodes) => (exists ? nodes : insertNode(nodes, parent, node, rootPath)),
967
1025
  async () => {
968
1026
  if (!writeOptions.force && writeOptions.expectedContent !== undefined) {
969
1027
  const live = await client.fsRead(
@@ -993,13 +1051,13 @@ export function useSandboxFiles(
993
1051
  exists ? [] : [parent],
994
1052
  );
995
1053
  },
996
- [client, workspaceId, sessionId, tree, runOptimistic],
1054
+ [client, workspaceId, sessionId, tree, rootPath, runOptimistic],
997
1055
  );
998
1056
 
999
1057
  const createFile = useCallback(
1000
1058
  async (path: string): Promise<void> => {
1001
1059
  if (!sessionId) throw new Error("no session");
1002
- const parent = parentOf(path);
1060
+ const parent = parentWithinTree(path, rootPath);
1003
1061
  const node: FileTreeNode = {
1004
1062
  path,
1005
1063
  name: leafOf(path),
@@ -1008,7 +1066,7 @@ export function useSandboxFiles(
1008
1066
  };
1009
1067
  await runOptimistic(
1010
1068
  "create file",
1011
- (nodes) => insertNode(nodes, parent, node),
1069
+ (nodes) => insertNode(nodes, parent, node, rootPath),
1012
1070
  () =>
1013
1071
  client.fsWrite(workspaceId, sessionId, {
1014
1072
  path,
@@ -1018,13 +1076,13 @@ export function useSandboxFiles(
1018
1076
  [parent],
1019
1077
  );
1020
1078
  },
1021
- [client, workspaceId, sessionId, runOptimistic],
1079
+ [client, workspaceId, sessionId, rootPath, runOptimistic],
1022
1080
  );
1023
1081
 
1024
1082
  const createDir = useCallback(
1025
1083
  async (path: string): Promise<void> => {
1026
1084
  if (!sessionId) throw new Error("no session");
1027
- const parent = parentOf(path);
1085
+ const parent = parentWithinTree(path, rootPath);
1028
1086
  // A freshly-created dir is empty + expanded: children:[] (not undefined, so
1029
1087
  // it doesn't show the lazy-expand marker over a dir we KNOW is empty).
1030
1088
  const node: FileTreeNode = {
@@ -1035,12 +1093,12 @@ export function useSandboxFiles(
1035
1093
  };
1036
1094
  await runOptimistic(
1037
1095
  "create folder",
1038
- (nodes) => insertNode(nodes, parent, node),
1096
+ (nodes) => insertNode(nodes, parent, node, rootPath),
1039
1097
  () => client.fsMkdir(workspaceId, sessionId, { path, recursive: true }),
1040
1098
  [parent],
1041
1099
  );
1042
1100
  },
1043
- [client, workspaceId, sessionId, runOptimistic],
1101
+ [client, workspaceId, sessionId, rootPath, runOptimistic],
1044
1102
  );
1045
1103
 
1046
1104
  const deleteEntry = useCallback(
@@ -1048,19 +1106,19 @@ export function useSandboxFiles(
1048
1106
  if (!sessionId) throw new Error("no session");
1049
1107
  await runOptimistic(
1050
1108
  "delete",
1051
- (nodes) => removeNode(nodes, path),
1109
+ (nodes) => removeNode(nodes, path, rootPath),
1052
1110
  () => client.fsDelete(workspaceId, sessionId, { path, recursive }),
1053
- [parentOf(path)],
1111
+ [parentWithinTree(path, rootPath)],
1054
1112
  );
1055
1113
  },
1056
- [client, workspaceId, sessionId, runOptimistic],
1114
+ [client, workspaceId, sessionId, rootPath, runOptimistic],
1057
1115
  );
1058
1116
 
1059
1117
  const moveEntry = useCallback(
1060
1118
  async (path: string, newPath: string, opts?: { overwrite?: boolean }): Promise<void> => {
1061
1119
  if (!sessionId) throw new Error("no session");
1062
- const from = parentOf(path);
1063
- const to = parentOf(newPath);
1120
+ const from = parentWithinTree(path, rootPath);
1121
+ const to = parentWithinTree(newPath, rootPath);
1064
1122
  await runOptimistic(
1065
1123
  "move",
1066
1124
  (nodes) => {
@@ -1068,7 +1126,7 @@ export function useSandboxFiles(
1068
1126
  if (!moving) return nodes; // not loaded — let the reconcile pick it up
1069
1127
  // Re-path the moved subtree, drop it from its old parent, splice into new.
1070
1128
  const moved = repathNode(moving, path, newPath);
1071
- return insertNode(removeNode(nodes, path), to, moved);
1129
+ return insertNode(removeNode(nodes, path, rootPath), to, moved, rootPath);
1072
1130
  },
1073
1131
  () =>
1074
1132
  client.fsMove(workspaceId, sessionId, {
@@ -1079,7 +1137,7 @@ export function useSandboxFiles(
1079
1137
  to === from ? [from] : [from, to],
1080
1138
  );
1081
1139
  },
1082
- [client, workspaceId, sessionId, runOptimistic],
1140
+ [client, workspaceId, sessionId, rootPath, runOptimistic],
1083
1141
  );
1084
1142
 
1085
1143
  // Initial paint + reset on identity change. Source selection:
@@ -1107,14 +1165,14 @@ export function useSandboxFiles(
1107
1165
  const identityChanged = previousIdentityRef.current !== identityKey;
1108
1166
  previousIdentityRef.current = identityKey;
1109
1167
  if (identityChanged || !enabled) {
1168
+ if (identityChanged) eventReconcileInFlightRef.current = null;
1110
1169
  identityAbortRef.current.abort();
1111
1170
  identityAbortRef.current = new AbortController();
1112
1171
  identityGenerationRef.current += 1;
1113
1172
  lastSeqRef.current = 0;
1114
1173
  pendingParentsRef.current = new Set();
1115
1174
  pendingGitRef.current = false;
1116
- pendingAgentToolRef.current = false;
1117
- lastCommandRefreshAtRef.current = 0;
1175
+ eventReconcilePendingRef.current = false;
1118
1176
  if (debounceRef.current) {
1119
1177
  clearTimeout(debounceRef.current);
1120
1178
  debounceRef.current = null;
@@ -1155,6 +1213,7 @@ export function useSandboxFiles(
1155
1213
  refreshAbortRef.current = null;
1156
1214
  refreshGenerationRef.current += 1;
1157
1215
  eventReadAbortRef.current.abort();
1216
+ eventReconcilePendingRef.current = false;
1158
1217
  };
1159
1218
  }, [
1160
1219
  enabled,
@@ -1196,7 +1255,7 @@ export function useSandboxFiles(
1196
1255
  for (const file of status.files) {
1197
1256
  const code = file.worktree ?? file.index;
1198
1257
  const mapped = code ? GIT_STATUS_TO_TREE[code] : undefined;
1199
- if (mapped) overlay.set(qualifyStatusPath(root, file.path), mapped);
1258
+ if (mapped) overlay.set(qualifyStatusPath(root, file.path, rootPath), mapped);
1200
1259
  }
1201
1260
  }
1202
1261
  statusRef.current = overlay;
@@ -1208,9 +1267,47 @@ export function useSandboxFiles(
1208
1267
  if (identityGenerationRef.current === identityGeneration) setGitLoading(false);
1209
1268
  }
1210
1269
  },
1211
- [client, workspaceId, sessionId, repoPaths, applyStatus],
1270
+ [client, workspaceId, sessionId, repoPaths, rootPath, applyStatus],
1212
1271
  );
1213
1272
 
1273
+ const performEventReconcile = useCallback(async () => {
1274
+ const parents = pendingParentsRef.current;
1275
+ pendingParentsRef.current = new Set();
1276
+ const wantGit = pendingGitRef.current;
1277
+ pendingGitRef.current = false;
1278
+ const signal = eventReadAbortRef.current.signal;
1279
+ const paths = [...parents];
1280
+
1281
+ // File content is the primary Files-tab result. Reconcile it first, then add
1282
+ // the optional Git tint through a second serial request. This matches the
1283
+ // API's exact-provider serialization instead of making the two reads contend.
1284
+ await reconcilePaths(paths, signal);
1285
+ if (wantGit) await refreshGitOverlay(signal);
1286
+ }, [reconcilePaths, refreshGitOverlay]);
1287
+ const performEventReconcileRef = useRef(performEventReconcile);
1288
+ performEventReconcileRef.current = performEventReconcile;
1289
+ const reconcilePendingEvents = useCallback(async () => {
1290
+ eventReconcilePendingRef.current = true;
1291
+ const existing = eventReconcileInFlightRef.current;
1292
+ if (existing) return await existing;
1293
+ const identityGeneration = identityGenerationRef.current;
1294
+ const run = (async () => {
1295
+ while (
1296
+ eventReconcilePendingRef.current &&
1297
+ identityGenerationRef.current === identityGeneration
1298
+ ) {
1299
+ eventReconcilePendingRef.current = false;
1300
+ await performEventReconcileRef.current();
1301
+ }
1302
+ })();
1303
+ eventReconcileInFlightRef.current = run;
1304
+ try {
1305
+ await run;
1306
+ } finally {
1307
+ if (eventReconcileInFlightRef.current === run) eventReconcileInFlightRef.current = null;
1308
+ }
1309
+ }, []);
1310
+
1214
1311
  // Auto-reconcile on fs/git change notifications — TARGETED, never a root
1215
1312
  // collapse-reload, and de-duped against our OWN mutations.
1216
1313
  //
@@ -1221,6 +1318,8 @@ export function useSandboxFiles(
1221
1318
  // reconciles ONLY the affected parent directories (in place, expansion
1222
1319
  // preserved). Bursts are debounced into a single reconcile pass.
1223
1320
  // • A git.changed just re-tints (refreshes the status overlay) — no fs re-list.
1321
+ // • Generic command/tool output performs no provider reads. The canonical
1322
+ // turn-end capture refreshes the root once after the command burst settles.
1224
1323
  const events = options.events;
1225
1324
 
1226
1325
  useEffect(() => {
@@ -1238,12 +1337,11 @@ export function useSandboxFiles(
1238
1337
  }
1239
1338
  pendingParentsRef.current = new Set();
1240
1339
  pendingGitRef.current = false;
1241
- pendingAgentToolRef.current = false;
1340
+ eventReconcilePendingRef.current = false;
1242
1341
  return;
1243
1342
  }
1244
1343
  let sawNew = false;
1245
1344
  let contentChanged = false;
1246
- let sawCommandDelta = false;
1247
1345
  for (const event of events) {
1248
1346
  if (event.sequence <= lastSeqRef.current) continue;
1249
1347
  if (event.type === "fs.changed") {
@@ -1256,8 +1354,10 @@ export function useSandboxFiles(
1256
1354
  continue;
1257
1355
  contentChanged = true;
1258
1356
  for (const change of payload.changes ?? []) {
1259
- pendingParentsRef.current.add(parentOf(change.path));
1260
- if (change.oldPath) pendingParentsRef.current.add(parentOf(change.oldPath));
1357
+ pendingParentsRef.current.add(parentWithinTree(change.path, rootPath));
1358
+ if (change.oldPath) {
1359
+ pendingParentsRef.current.add(parentWithinTree(change.oldPath, rootPath));
1360
+ }
1261
1361
  }
1262
1362
  } else if (event.type === "git.changed") {
1263
1363
  sawNew = true;
@@ -1273,73 +1373,27 @@ export function useSandboxFiles(
1273
1373
  )
1274
1374
  continue;
1275
1375
  pendingGitRef.current = true;
1276
- } else if (event.type === "agent.toolCall.output") {
1277
- // Agent shell/apply-patch writes do not necessarily pass through the
1278
- // structured FS service, so they may have no fs.changed notification.
1279
- // A completed tool is a bounded invalidation point: refresh only the
1280
- // currently-loaded directory frontier, never poll and never traverse a
1281
- // collapsed subtree.
1282
- sawNew = true;
1283
- contentChanged = true;
1284
- pendingAgentToolRef.current = true;
1285
- // Shell/apply-patch writes bypass the structured Git service too, so no
1286
- // git.changed event is guaranteed. Refresh the summary/tints alongside
1287
- // the visible tree frontier; both remote reads can run concurrently.
1288
- pendingGitRef.current = true;
1289
- } else if (event.type === "sandbox.command.output.delta") {
1290
- // Ordinary shell writes can bypass Channel-A and therefore emit no
1291
- // fs.changed/git.changed event until the command completes. Reconcile
1292
- // the visible frontier at most once per second while output streams so
1293
- // Files and Changes stay useful without turning the dock into polling.
1294
- sawNew = true;
1295
- contentChanged = true;
1296
- sawCommandDelta = true;
1297
- pendingAgentToolRef.current = true;
1298
- pendingGitRef.current = true;
1299
1376
  }
1300
1377
  }
1301
1378
  // Advance the high-water mark past everything we've folded.
1302
1379
  for (const event of events)
1303
1380
  if (event.sequence > lastSeqRef.current) lastSeqRef.current = event.sequence;
1304
1381
  if (!sawNew) return;
1305
- if (contentChanged && !sawCommandDelta) setContentRevision((revision) => revision + 1);
1382
+ if (contentChanged) setContentRevision((revision) => revision + 1);
1306
1383
 
1307
1384
  if (debounceRef.current) clearTimeout(debounceRef.current);
1308
1385
  const identityGeneration = identityGenerationRef.current;
1309
- const delay = sawCommandDelta
1310
- ? Math.max(0, 1_000 - (Date.now() - lastCommandRefreshAtRef.current))
1311
- : 150;
1312
1386
  debounceRef.current = setTimeout(() => {
1313
1387
  debounceRef.current = null;
1314
1388
  if (identityGenerationRef.current !== identityGeneration) return;
1315
1389
  if (!acceptsEventReadsRef.current) {
1316
1390
  pendingParentsRef.current = new Set();
1317
1391
  pendingGitRef.current = false;
1318
- pendingAgentToolRef.current = false;
1319
1392
  return;
1320
1393
  }
1321
- if (sawCommandDelta) {
1322
- lastCommandRefreshAtRef.current = Date.now();
1323
- setContentRevision((revision) => revision + 1);
1324
- }
1325
- const parents = pendingParentsRef.current;
1326
- pendingParentsRef.current = new Set();
1327
- const wantGit = pendingGitRef.current;
1328
- pendingGitRef.current = false;
1329
- const wantAgentTool = pendingAgentToolRef.current;
1330
- pendingAgentToolRef.current = false;
1331
- // Reconcile the changed directories and Git overlay concurrently. Both are
1332
- // independent remote reads; applyStatus makes their completion order safe.
1333
- const eventReadSignal = eventReadAbortRef.current.signal;
1334
- void (async () => {
1335
- const paths = wantAgentTool ? loadedDirectoryPaths(treeRef.current) : [...parents];
1336
- await Promise.all([
1337
- wantGit ? refreshGitOverlay(eventReadSignal) : Promise.resolve(),
1338
- Promise.all(paths.map((path) => reconcilePath(path, eventReadSignal))),
1339
- ]);
1340
- })();
1341
- }, delay);
1342
- }, [enabled, active, acceptsEventReads, events, reconcilePath, refreshGitOverlay]);
1394
+ void reconcilePendingEvents();
1395
+ }, 150);
1396
+ }, [enabled, active, acceptsEventReads, events, rootPath, reconcilePendingEvents]);
1343
1397
 
1344
1398
  useEffect(() => {
1345
1399
  if (active && acceptsEventReads) return;
@@ -1349,7 +1403,7 @@ export function useSandboxFiles(
1349
1403
  }
1350
1404
  pendingParentsRef.current = new Set();
1351
1405
  pendingGitRef.current = false;
1352
- pendingAgentToolRef.current = false;
1406
+ eventReconcilePendingRef.current = false;
1353
1407
  }, [active, acceptsEventReads]);
1354
1408
 
1355
1409
  useEffect(
@@ -1357,6 +1411,7 @@ export function useSandboxFiles(
1357
1411
  refreshAbortRef.current?.abort();
1358
1412
  identityAbortRef.current.abort();
1359
1413
  eventReadAbortRef.current.abort();
1414
+ eventReconcilePendingRef.current = false;
1360
1415
  identityGenerationRef.current += 1;
1361
1416
  if (debounceRef.current) clearTimeout(debounceRef.current);
1362
1417
  },