@opengeni/react 0.11.0 → 0.13.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 (85) hide show
  1. package/README.md +1 -1
  2. package/dist/{chunk-5C7RGAWA.js → chunk-NFYVQWIB.js} +170 -64
  3. package/dist/chunk-NFYVQWIB.js.map +1 -0
  4. package/dist/index.d.ts +791 -145
  5. package/dist/index.js +5447 -2213
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-BeZ3bD3t.d.ts → machines-CnlMb7E-.d.ts} +5 -5
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +1 -1
  10. package/package.json +17 -12
  11. package/src/approvals.ts +16 -4
  12. package/src/client.ts +32 -5
  13. package/src/commands/index.ts +1 -7
  14. package/src/commands/registry.ts +46 -12
  15. package/src/components/chat-composer.tsx +135 -39
  16. package/src/components/code-editor.tsx +28 -36
  17. package/src/components/command-palette.tsx +26 -5
  18. package/src/components/desktop-viewer.tsx +29 -16
  19. package/src/components/diff-view.tsx +27 -189
  20. package/src/components/enrollment-consent.tsx +28 -10
  21. package/src/components/enrollment-device-flow.tsx +8 -5
  22. package/src/components/file-browser.tsx +190 -56
  23. package/src/components/fleet-tile.tsx +13 -4
  24. package/src/components/machine-card.tsx +16 -4
  25. package/src/components/machine-dock-bar.tsx +20 -8
  26. package/src/components/machine-metrics.tsx +31 -8
  27. package/src/components/machine-status-pill.tsx +26 -5
  28. package/src/components/machines-dashboard.tsx +8 -2
  29. package/src/components/markdown.tsx +39 -9
  30. package/src/components/message-timeline.tsx +633 -109
  31. package/src/components/pierre-diff.tsx +83 -15
  32. package/src/components/pierre-file.tsx +10 -9
  33. package/src/components/sandbox-files.tsx +110 -227
  34. package/src/components/sandbox-terminal.tsx +256 -88
  35. package/src/components/sandbox-workspace.tsx +823 -0
  36. package/src/components/session-status.tsx +28 -2
  37. package/src/components/workbench-changes.tsx +541 -0
  38. package/src/components/workspace-dock.tsx +85 -22
  39. package/src/hooks/internal.ts +5 -2
  40. package/src/hooks/use-available-models.ts +7 -2
  41. package/src/hooks/use-billing-usage.ts +4 -1
  42. package/src/hooks/use-codex-accounts.ts +74 -44
  43. package/src/hooks/use-composer.ts +57 -36
  44. package/src/hooks/use-environments.ts +92 -47
  45. package/src/hooks/use-file-attachments.ts +101 -55
  46. package/src/hooks/use-goal.ts +52 -16
  47. package/src/hooks/use-machine-chip.ts +134 -0
  48. package/src/hooks/use-machines.ts +34 -16
  49. package/src/hooks/use-packs.ts +24 -19
  50. package/src/hooks/use-relay-frame-stream.ts +5 -2
  51. package/src/hooks/use-rigs.ts +335 -0
  52. package/src/hooks/use-sandbox-files.ts +213 -39
  53. package/src/hooks/use-sandbox-git.ts +188 -11
  54. package/src/hooks/use-sandbox-terminal.ts +18 -14
  55. package/src/hooks/use-scheduled-tasks.ts +10 -2
  56. package/src/hooks/use-session-capabilities.ts +77 -20
  57. package/src/hooks/use-session-control.ts +49 -21
  58. package/src/hooks/use-session-events.ts +48 -20
  59. package/src/hooks/use-session-lineage.ts +119 -0
  60. package/src/hooks/use-session.ts +43 -28
  61. package/src/hooks/use-slash-commands.ts +6 -4
  62. package/src/hooks/use-turn-queue.ts +74 -147
  63. package/src/hooks/use-windowed-sections.ts +204 -0
  64. package/src/hooks/use-workspace-capture.ts +233 -0
  65. package/src/hooks/use-workspace-edit.ts +231 -0
  66. package/src/hooks/use-workspace-sessions.ts +48 -5
  67. package/src/hooks/use-workspaces.ts +18 -15
  68. package/src/index.ts +128 -23
  69. package/src/lib/format.ts +44 -6
  70. package/src/lib/xterm-renderer.ts +65 -0
  71. package/src/lib/xterm-theme.ts +224 -18
  72. package/src/machines.ts +13 -3
  73. package/src/provider.tsx +3 -1
  74. package/src/timeline/activity-rail.tsx +213 -54
  75. package/src/timeline/disclosure-context.tsx +12 -2
  76. package/src/timeline/index.ts +21 -2
  77. package/src/timeline/parsers.ts +44 -10
  78. package/src/timeline/projection.ts +435 -84
  79. package/src/timeline/shared.tsx +111 -24
  80. package/src/timeline/tool-diff.tsx +24 -20
  81. package/src/timeline/tool-renderers.tsx +98 -21
  82. package/src/timeline/turn-summary.tsx +76 -23
  83. package/src/timeline/types.ts +96 -12
  84. package/styles/tokens.css +2 -2
  85. package/dist/chunk-5C7RGAWA.js.map +0 -1
@@ -6,6 +6,7 @@ import type {
6
6
  GitChangedPayload,
7
7
  GitFileStatusCode,
8
8
  SessionEvent,
9
+ WorkspaceCaptureManifest,
9
10
  } from "@opengeni/sdk";
10
11
  import { useCallback, useEffect, useRef, useState } from "react";
11
12
  import { useOpenGeni, type ClientOverride } from "../provider";
@@ -22,6 +23,10 @@ export type FileTreeNode = {
22
23
  children?: FileTreeNode[] | undefined;
23
24
  size?: number | null | undefined;
24
25
  status?: FileTreeStatus | undefined;
26
+ /** A residue dir the capture COLLAPSED (node_modules, .git, dist, …): its
27
+ * contents were never indexed. Cold, expanding it can't list children — the
28
+ * UI shows an inline "contents on machine" row until the box is warm. */
29
+ truncated?: boolean | undefined;
25
30
  };
26
31
 
27
32
  export type UseSandboxFilesOptions = ClientOverride & {
@@ -38,6 +43,12 @@ export type UseSandboxFilesOptions = ClientOverride & {
38
43
  * (with no `fs.changed` event) never re-lists. Passing liveness re-lists when
39
44
  * the box first becomes warm, so the tree populates as soon as the box is up. */
40
45
  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). */
51
+ capture?: WorkspaceCaptureManifest | null | undefined;
41
52
  /** Called when an OPTIMISTIC mutation is reverted because its background
42
53
  * Channel-A op failed (e.g. a 409 rename collision). The host wires this to a
43
54
  * toast — the tree silently rolls the node back, the user sees why. */
@@ -68,6 +79,11 @@ export type UseSandboxFilesResult = {
68
79
  moveEntry: (path: string, newPath: string, opts?: { overwrite?: boolean }) => Promise<void>;
69
80
  /** Re-list the whole tree from the root. */
70
81
  refresh: () => Promise<void>;
82
+ /** Which source the tree is currently served from: the live box, the turn-end
83
+ * capture (cold/offline), or neither yet. M5's source badge reads this. */
84
+ source: "live" | "capture" | null;
85
+ /** When the served capture was taken (ISO), when `source === "capture"`. */
86
+ capturedAt: string | null;
71
87
  loading: boolean;
72
88
  error: Error | null;
73
89
  };
@@ -85,10 +101,6 @@ function leafOf(path: string): string {
85
101
  }
86
102
 
87
103
  /** Join a parent dir with a leaf name (handles the root "" parent). */
88
- function joinPath(parent: string, name: string): string {
89
- return parent ? `${parent}/${name}` : name;
90
- }
91
-
92
104
  /** Stable sibling ordering: dirs before files, then case-insensitive by name —
93
105
  * matches a typical depth-1 list so an optimistic insert lands where a real
94
106
  * re-list would put it (no jump when the server reconciles). */
@@ -118,6 +130,9 @@ function fsNodeToTree(node: FsTreeNode): FileTreeNode {
118
130
  kind,
119
131
  size: node.sizeBytes,
120
132
  ...(kind === "dir" ? { children: mappedChildren } : {}),
133
+ // A collapsed residue dir from the capture index — carried through so the
134
+ // tree can show an inline "contents on machine" row cold instead of nothing.
135
+ ...(kind === "dir" && node.truncated ? { truncated: true } : {}),
121
136
  };
122
137
  }
123
138
 
@@ -132,7 +147,11 @@ const GIT_STATUS_TO_TREE: Partial<Record<GitFileStatusCode, FileTreeStatus>> = {
132
147
  };
133
148
 
134
149
  /** Replace the children of `targetPath` within the tree (immutably). */
135
- function replaceChildren(nodes: FileTreeNode[], targetPath: string, children: FileTreeNode[]): FileTreeNode[] {
150
+ function replaceChildren(
151
+ nodes: FileTreeNode[],
152
+ targetPath: string,
153
+ children: FileTreeNode[],
154
+ ): FileTreeNode[] {
136
155
  return nodes.map((node) => {
137
156
  if (node.path === targetPath) {
138
157
  return { ...node, children };
@@ -223,14 +242,34 @@ function removeNode(nodes: FileTreeNode[], path: string): FileTreeNode[] {
223
242
  * and entries the server no longer returns are dropped. This is the in-place
224
243
  * merge a root ("") reconcile needs — a blind replace would collapse every
225
244
  * expanded top-level dir back to the unexpanded marker (the reported bug). */
245
+ /** Shallow node equality for identity preservation across a reconcile — same
246
+ * path/kind/name/size/status (children handled separately by the caller). */
247
+ function sameNodeShallow(a: FileTreeNode, b: FileTreeNode): boolean {
248
+ return (
249
+ a.path === b.path &&
250
+ a.kind === b.kind &&
251
+ a.name === b.name &&
252
+ (a.size ?? null) === (b.size ?? null) &&
253
+ (a.status ?? undefined) === (b.status ?? undefined) &&
254
+ (a.truncated ?? false) === (b.truncated ?? false)
255
+ );
256
+ }
257
+
226
258
  function mergeChildren(current: FileTreeNode[], listed: FileTreeNode[]): FileTreeNode[] {
227
259
  const byPath = new Map(current.map((n) => [n.path, n] as const));
228
260
  const merged = listed.map((next) => {
229
261
  const existing = byPath.get(next.path);
262
+ if (!existing) return next; // brand-new entry
230
263
  // Keep an already-expanded dir's loaded children; otherwise take the listing's
231
264
  // marker (undefined = unexpanded). Carry forward size/status from the listing.
232
- if (existing && existing.kind === "dir" && next.kind === "dir" && existing.children !== undefined) {
233
- return { ...next, children: existing.children };
265
+ if (existing.kind === "dir" && next.kind === "dir" && existing.children !== undefined) {
266
+ const candidate: FileTreeNode = { ...next, children: existing.children };
267
+ // Preserve identity when nothing observable changed — no remount (§12-D1).
268
+ return sameNodeShallow(existing, candidate) ? existing : candidate;
269
+ }
270
+ // A file or an unexpanded dir: preserve identity when the node is unchanged.
271
+ if (existing.children === next.children && sameNodeShallow(existing, next)) {
272
+ return existing;
234
273
  }
235
274
  return next;
236
275
  });
@@ -276,7 +315,17 @@ export function useSandboxFiles(
276
315
  const [loading, setLoading] = useState(false);
277
316
  const [expandingPaths, setExpandingPaths] = useState<Set<string>>(new Set());
278
317
  const [error, setError] = useState<Error | null>(null);
318
+ // Which source the tree currently reflects — "capture" (cold paint) or "live".
319
+ const [source, setSource] = useState<"live" | "capture" | null>(null);
279
320
  const statusRef = useRef<Map<string, FileTreeStatus>>(new Map());
321
+ // Async reads, event cursors, and debounced work are scoped to the hook's
322
+ // workspace/session/root identity. These refs are reset/fenced at that boundary.
323
+ const refreshGenerationRef = useRef(0);
324
+ const identityGenerationRef = useRef(0);
325
+ const lastSeqRef = useRef(0);
326
+ const pendingParentsRef = useRef<Set<string>>(new Set());
327
+ const pendingGitRef = useRef(false);
328
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
280
329
 
281
330
  // ── Self-emitted fs.changed de-dupe ───────────────────────────────────────
282
331
  // Every one of OUR mutations emits an `fs.changed` event back on the live log
@@ -292,22 +341,38 @@ export function useSandboxFiles(
292
341
  // Keep the ref mirror current for the optimistic snapshot path.
293
342
  treeRef.current = tree;
294
343
 
344
+ // Overlay the git-status tint onto the tree. IDENTITY-PRESERVING: a node whose
345
+ // tint and children are unchanged is returned by reference (not rebuilt), so a
346
+ // 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
348
+ // meaningful: it strips stale tints after the repository becomes clean.
295
349
  const applyStatus = useCallback((nodes: FileTreeNode[]): FileTreeNode[] => {
296
350
  const overlay = statusRef.current;
297
- if (overlay.size === 0) return nodes;
298
- const walk = (list: FileTreeNode[]): FileTreeNode[] =>
299
- list.map((node) => {
300
- const status = overlay.get(node.path);
301
- const next = node.children ? { ...node, children: walk(node.children) } : { ...node };
302
- if (status && node.kind === "file") next.status = status;
351
+ const walk = (list: FileTreeNode[]): FileTreeNode[] => {
352
+ let changed = false;
353
+ const out = list.map((node) => {
354
+ const nextChildren = node.children ? walk(node.children) : node.children;
355
+ const wantStatus = node.kind === "file" ? overlay.get(node.path) : undefined;
356
+ const childrenChanged = nextChildren !== node.children;
357
+ const statusChanged = (node.status ?? undefined) !== (wantStatus ?? undefined);
358
+ if (!childrenChanged && !statusChanged) return node;
359
+ changed = true;
360
+ const next: FileTreeNode = {
361
+ ...node,
362
+ ...(node.children ? { children: nextChildren } : {}),
363
+ };
364
+ if (wantStatus) next.status = wantStatus;
303
365
  else delete next.status;
304
366
  return next;
305
367
  });
368
+ return changed ? out : list;
369
+ };
306
370
  return walk(nodes);
307
371
  }, []);
308
372
 
309
373
  const refresh = useCallback(async () => {
310
374
  if (!sessionId) return;
375
+ const generation = (refreshGenerationRef.current += 1);
311
376
  setLoading(true);
312
377
  setError(null);
313
378
  try {
@@ -315,6 +380,7 @@ export function useSandboxFiles(
315
380
  // returns isRepo:false), then the tree, so the first paint is tinted.
316
381
  try {
317
382
  const status = await client.gitStatus(workspaceId, sessionId, { path: rootPath });
383
+ if (refreshGenerationRef.current !== generation) return;
318
384
  const overlay = new Map<string, FileTreeStatus>();
319
385
  for (const file of status.files) {
320
386
  const code = file.worktree ?? file.index;
@@ -323,24 +389,57 @@ export function useSandboxFiles(
323
389
  }
324
390
  statusRef.current = overlay;
325
391
  } catch {
392
+ if (refreshGenerationRef.current !== generation) return;
326
393
  statusRef.current = new Map();
327
394
  }
328
395
  const listed = await client.fsList(workspaceId, sessionId, { path: rootPath, depth: 1 });
396
+ if (refreshGenerationRef.current !== generation) return;
329
397
  const children = (listed.root.children ?? []).map(fsNodeToTree);
330
398
  // Merge rather than replace so an explicit refresh / cold→warm re-list folds
331
- // in new entries WITHOUT collapsing the dirs the user already expanded. On a
332
- // first (empty) load this is a plain set.
333
- setTree((prev) => applyStatus(prev.length === 0 ? children : mergeRootChildren(prev, children)));
399
+ // in new entries WITHOUT collapsing the dirs the user already expanded (and,
400
+ // via the identity-preserving merge, WITHOUT remounting unchanged rows — the
401
+ // no-flicker cold→warm reconcile). On a first (empty) load this is a plain set.
402
+ setTree((prev) =>
403
+ applyStatus(prev.length === 0 ? children : mergeRootChildren(prev, children)),
404
+ );
405
+ // Live data is now serving — flip the source off the capture snapshot.
406
+ setSource("live");
334
407
  } catch (cause) {
408
+ if (refreshGenerationRef.current !== generation) return;
335
409
  setError(cause instanceof Error ? cause : new Error(String(cause)));
336
410
  } finally {
337
- setLoading(false);
411
+ if (refreshGenerationRef.current === generation) setLoading(false);
338
412
  }
339
413
  }, [client, workspaceId, sessionId, rootPath, applyStatus]);
340
414
 
415
+ // Seed (or re-seed) the tree from a turn-end capture — the COLD/offline paint,
416
+ // zero Channel-A calls. The tree index is workspace-relative (`treeIndex`); the
417
+ // tint overlay comes from the capture's changed `files`. Uses the SAME merge as
418
+ // 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.
420
+ const seedFromCapture = useCallback(
421
+ (manifest: WorkspaceCaptureManifest) => {
422
+ const overlay = new Map<string, FileTreeStatus>();
423
+ for (const file of manifest.files) {
424
+ if (file.deleted) continue;
425
+ const mapped = GIT_STATUS_TO_TREE[file.status];
426
+ if (mapped) overlay.set(file.path, mapped);
427
+ }
428
+ statusRef.current = overlay;
429
+ const children = (manifest.treeIndex.children ?? []).map(fsNodeToTree);
430
+ setTree((prev) =>
431
+ applyStatus(prev.length === 0 ? children : mergeRootChildren(prev, children)),
432
+ );
433
+ setSource("capture");
434
+ setError(null);
435
+ },
436
+ [applyStatus],
437
+ );
438
+
341
439
  const expand = useCallback(
342
440
  async (path: string) => {
343
441
  if (!sessionId) return;
442
+ const identityGeneration = identityGenerationRef.current;
344
443
  // Mark this node as expanding so the FileBrowser can render a spinner while
345
444
  // the (often 2-3s) Channel-A fs/list is in flight — the tree never looks
346
445
  // frozen on a click.
@@ -351,17 +450,21 @@ export function useSandboxFiles(
351
450
  });
352
451
  try {
353
452
  const listed = await client.fsList(workspaceId, sessionId, { path, depth: 1 });
453
+ if (identityGenerationRef.current !== identityGeneration) return;
354
454
  const children = (listed.root.children ?? []).map(fsNodeToTree);
355
455
  setTree((prev) => applyStatus(replaceChildren(prev, path, children)));
356
456
  } catch (cause) {
457
+ if (identityGenerationRef.current !== identityGeneration) return;
357
458
  setError(cause instanceof Error ? cause : new Error(String(cause)));
358
459
  } finally {
359
- setExpandingPaths((prev) => {
360
- if (!prev.has(path)) return prev;
361
- const next = new Set(prev);
362
- next.delete(path);
363
- return next;
364
- });
460
+ if (identityGenerationRef.current === identityGeneration) {
461
+ setExpandingPaths((prev) => {
462
+ if (!prev.has(path)) return prev;
463
+ const next = new Set(prev);
464
+ next.delete(path);
465
+ return next;
466
+ });
467
+ }
365
468
  }
366
469
  },
367
470
  [client, workspaceId, sessionId, applyStatus],
@@ -385,17 +488,21 @@ export function useSandboxFiles(
385
488
  const reconcilePath = useCallback(
386
489
  async (path: string) => {
387
490
  if (!sessionId) return;
491
+ const identityGeneration = identityGenerationRef.current;
388
492
  // Skip parents that aren't currently loaded/expanded in the tree (nothing
389
493
  // visible to update; they re-list fresh on the next expand).
390
494
  if (!parentIsLoaded(treeRef.current, path)) return;
391
495
  try {
392
496
  const listed = await client.fsList(workspaceId, sessionId, { path, depth: 1 });
497
+ if (identityGenerationRef.current !== identityGeneration) return;
393
498
  const children = (listed.root.children ?? []).map(fsNodeToTree);
394
499
  if (path === "") setTree((prev) => applyStatus(mergeRootChildren(prev, children)));
395
500
  else
396
501
  setTree((prev) => {
397
502
  const existing = findNodeByPath(prev, path);
398
- const merged = existing?.children ? mergeChildren(existing.children, children) : children;
503
+ const merged = existing?.children
504
+ ? mergeChildren(existing.children, children)
505
+ : children;
399
506
  return applyStatus(replaceChildren(prev, path, merged));
400
507
  });
401
508
  } catch {
@@ -414,17 +521,19 @@ export function useSandboxFiles(
414
521
  // refresh. The returned promise resolves/rejects with the op so callers (the
415
522
  // editor save, inline rename) can still await it.
416
523
  const runOptimistic = useCallback(
417
- async <T,>(
524
+ async <T>(
418
525
  opName: string,
419
526
  apply: (nodes: FileTreeNode[]) => FileTreeNode[],
420
527
  op: () => Promise<T>,
421
528
  reconcileParents: string[],
422
529
  ): Promise<T> => {
530
+ const identityGeneration = identityGenerationRef.current;
423
531
  // Snapshot the pre-op tree from the ref (StrictMode-safe — see treeRef).
424
532
  const snapshot = treeRef.current;
425
533
  setTree(applyStatus(apply(snapshot)));
426
534
  try {
427
535
  const res = await op();
536
+ if (identityGenerationRef.current !== identityGeneration) return res;
428
537
  // Remember the revision WE caused so the matching fs.changed echo is
429
538
  // ignored by the event effect (no self-triggered refresh).
430
539
  if (res && typeof res === "object" && "revision" in res) {
@@ -433,10 +542,14 @@ export function useSandboxFiles(
433
542
  }
434
543
  // Reconcile loaded parents to fold the server's real metadata. Sequential
435
544
  // and best-effort — purely cosmetic over the already-correct optimistic UI.
436
- for (const parent of reconcileParents) await reconcilePath(parent);
545
+ for (const parent of reconcileParents) {
546
+ if (identityGenerationRef.current !== identityGeneration) return res;
547
+ await reconcilePath(parent);
548
+ }
437
549
  return res;
438
550
  } catch (cause) {
439
551
  const err = cause instanceof Error ? cause : new Error(String(cause));
552
+ if (identityGenerationRef.current !== identityGeneration) throw err;
440
553
  // Revert the optimistic edit to the exact pre-op tree.
441
554
  setTree(applyStatus(snapshot));
442
555
  setError(err);
@@ -537,22 +650,71 @@ export function useSandboxFiles(
537
650
  [client, workspaceId, sessionId, runOptimistic],
538
651
  );
539
652
 
540
- // Initial load + reset on identity change.
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).
658
+ // • 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
+ // Key the seed on the capture's REVISION (a primitive), not the manifest object
662
+ // — a consumer passing a fresh object each render (or a new revision) must not
663
+ // spin the effect. The latest manifest is read from a ref at run time.
664
+ const captureRef = useRef<WorkspaceCaptureManifest | null>(capture);
665
+ captureRef.current = capture;
666
+ const captureRevision = capture?.revision ?? null;
667
+ const identityKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${rootPath}`;
668
+ const previousIdentityRef = useRef(identityKey);
541
669
  useEffect(() => {
542
- if (!enabled) {
670
+ // Any source-selection change supersedes an older root refresh. A true data
671
+ // identity change additionally fences all other async tree work and resets
672
+ // event/debounce state before the event-folding effect runs.
673
+ refreshGenerationRef.current += 1;
674
+ const identityChanged = previousIdentityRef.current !== identityKey;
675
+ previousIdentityRef.current = identityKey;
676
+ if (identityChanged || !enabled) {
677
+ identityGenerationRef.current += 1;
678
+ lastSeqRef.current = 0;
679
+ pendingParentsRef.current = new Set();
680
+ pendingGitRef.current = false;
681
+ if (debounceRef.current) {
682
+ clearTimeout(debounceRef.current);
683
+ debounceRef.current = null;
684
+ }
685
+ ownRevisionsRef.current = new Set();
686
+ statusRef.current = new Map();
687
+ treeRef.current = [];
543
688
  setTree([]);
689
+ setExpandingPaths(new Set());
690
+ setSource(null);
691
+ setLoading(false);
692
+ setError(null);
693
+ }
694
+ if (!enabled) {
544
695
  return;
545
696
  }
546
- void refresh();
547
- }, [enabled, refresh]);
697
+ if (isLive) {
698
+ void refresh();
699
+ } else if (captureRevision !== null && captureRef.current) {
700
+ seedFromCapture(captureRef.current);
701
+ } else {
702
+ void refresh();
703
+ }
704
+ return () => {
705
+ refreshGenerationRef.current += 1;
706
+ };
707
+ }, [enabled, isLive, captureRevision, identityKey, refresh, seedFromCapture]);
548
708
 
549
709
  // Re-pull JUST the git-status overlay and re-tint the existing tree in place —
550
710
  // no fs re-list, no collapse. This is all a `git.changed` (commit/stage/checkout)
551
711
  // needs: the tree SHAPE is unchanged, only the tints move.
552
712
  const refreshGitOverlay = useCallback(async () => {
553
713
  if (!sessionId) return;
714
+ const identityGeneration = identityGenerationRef.current;
554
715
  try {
555
716
  const status = await client.gitStatus(workspaceId, sessionId, { path: rootPath });
717
+ if (identityGenerationRef.current !== identityGeneration) return;
556
718
  const overlay = new Map<string, FileTreeStatus>();
557
719
  for (const file of status.files) {
558
720
  const code = file.worktree ?? file.index;
@@ -577,10 +739,6 @@ export function useSandboxFiles(
577
739
  // preserved). Bursts are debounced into a single reconcile pass.
578
740
  // • A git.changed just re-tints (refreshes the status overlay) — no fs re-list.
579
741
  const events = options.events;
580
- const lastSeqRef = useRef(0);
581
- const pendingParentsRef = useRef<Set<string>>(new Set());
582
- const pendingGitRef = useRef(false);
583
- const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
584
742
 
585
743
  useEffect(() => {
586
744
  if (!enabled || !events) return;
@@ -593,7 +751,8 @@ export function useSandboxFiles(
593
751
  if (!payload || typeof payload !== "object") continue;
594
752
  // Suppress our own writes: the optimistic tree already shows them.
595
753
  if (payload.source === "write") continue;
596
- if (typeof payload.revision === "number" && ownRevisionsRef.current.has(payload.revision)) continue;
754
+ if (typeof payload.revision === "number" && ownRevisionsRef.current.has(payload.revision))
755
+ continue;
597
756
  for (const change of payload.changes ?? []) {
598
757
  pendingParentsRef.current.add(parentOf(change.path));
599
758
  if (change.oldPath) pendingParentsRef.current.add(parentOf(change.oldPath));
@@ -604,18 +763,26 @@ export function useSandboxFiles(
604
763
  // A git.changed our own write triggered (commit/stage from the agent is
605
764
  // external; a checkout we caused isn't — but we don't cause git ops here,
606
765
  // so any git.changed is external) → re-tint.
607
- if (payload && typeof payload === "object" && typeof payload.revision === "number"
608
- && ownRevisionsRef.current.has(payload.revision)) continue;
766
+ if (
767
+ payload &&
768
+ typeof payload === "object" &&
769
+ typeof payload.revision === "number" &&
770
+ ownRevisionsRef.current.has(payload.revision)
771
+ )
772
+ continue;
609
773
  pendingGitRef.current = true;
610
774
  }
611
775
  }
612
776
  // Advance the high-water mark past everything we've folded.
613
- for (const event of events) if (event.sequence > lastSeqRef.current) lastSeqRef.current = event.sequence;
777
+ for (const event of events)
778
+ if (event.sequence > lastSeqRef.current) lastSeqRef.current = event.sequence;
614
779
  if (!sawNew) return;
615
780
 
616
781
  if (debounceRef.current) clearTimeout(debounceRef.current);
782
+ const identityGeneration = identityGenerationRef.current;
617
783
  debounceRef.current = setTimeout(() => {
618
784
  debounceRef.current = null;
785
+ if (identityGenerationRef.current !== identityGeneration) return;
619
786
  const parents = pendingParentsRef.current;
620
787
  pendingParentsRef.current = new Set();
621
788
  const wantGit = pendingGitRef.current;
@@ -624,13 +791,18 @@ export function useSandboxFiles(
624
791
  // so the freshly-listed nodes get the correct overlay.
625
792
  void (async () => {
626
793
  if (wantGit) await refreshGitOverlay();
627
- for (const parent of parents) await reconcilePath(parent);
794
+ if (identityGenerationRef.current !== identityGeneration) return;
795
+ for (const parent of parents) {
796
+ if (identityGenerationRef.current !== identityGeneration) return;
797
+ await reconcilePath(parent);
798
+ }
628
799
  })();
629
800
  }, 150);
630
801
  }, [enabled, events, reconcilePath, refreshGitOverlay]);
631
802
 
632
803
  useEffect(
633
804
  () => () => {
805
+ identityGenerationRef.current += 1;
634
806
  if (debounceRef.current) clearTimeout(debounceRef.current);
635
807
  },
636
808
  [],
@@ -664,6 +836,8 @@ export function useSandboxFiles(
664
836
  deleteEntry,
665
837
  moveEntry,
666
838
  refresh,
839
+ source,
840
+ capturedAt: source === "capture" ? (capture?.capturedAt ?? null) : null,
667
841
  loading,
668
842
  error,
669
843
  };