@opengeni/react 0.45.0 → 0.48.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 (141) hide show
  1. package/README.md +68 -12
  2. package/dist/{chunk-U3CSCMWA.js → chunk-FK6VG4LL.js} +9 -13
  3. package/dist/chunk-FK6VG4LL.js.map +1 -0
  4. package/dist/{chunk-HFO4ERGQ.js → chunk-FWXGSNXT.js} +2 -2
  5. package/dist/{chunk-SRFUT2ZU.js → chunk-I4MPFDRP.js} +119 -63
  6. package/dist/chunk-I4MPFDRP.js.map +1 -0
  7. package/dist/{chunk-EB67J347.js → chunk-KZ73RL76.js} +111 -31
  8. package/dist/chunk-KZ73RL76.js.map +1 -0
  9. package/dist/{chunk-LWR4MXSS.js → chunk-LHLYWG5R.js} +10 -1
  10. package/dist/chunk-LHLYWG5R.js.map +1 -0
  11. package/dist/{chunk-L3EWO3UK.js → chunk-NJ7M2B6Z.js} +445 -84
  12. package/dist/chunk-NJ7M2B6Z.js.map +1 -0
  13. package/dist/{chunk-VCBFUSUZ.js → chunk-O4VXOBGF.js} +3 -3
  14. package/dist/{chunk-VCBFUSUZ.js.map → chunk-O4VXOBGF.js.map} +1 -1
  15. package/dist/{chunk-6EDV6YFE.js → chunk-OL6QVXGF.js} +121 -29
  16. package/dist/chunk-OL6QVXGF.js.map +1 -0
  17. package/dist/chunk-VZTQ73XT.js +86 -0
  18. package/dist/chunk-VZTQ73XT.js.map +1 -0
  19. package/dist/{chunk-EHSYZ4KP.js → chunk-WYA65Y3F.js} +16 -16
  20. package/dist/chunk-WYA65Y3F.js.map +1 -0
  21. package/dist/{chunk-FD72GJMK.js → chunk-XCNTXWBB.js} +274 -125
  22. package/dist/chunk-XCNTXWBB.js.map +1 -0
  23. package/dist/client.d.ts +1 -1
  24. package/dist/components/chat-composer.d.ts +7 -2
  25. package/dist/components/composer-transcription-control.d.ts +2 -0
  26. package/dist/components/composer.d.ts +14 -0
  27. package/dist/components/machines/machine-detail.d.ts +3 -1
  28. package/dist/components/machines-dashboard.d.ts +3 -3
  29. package/dist/components/message-timeline.d.ts +12 -2
  30. package/dist/components/sandbox-files.d.ts +2 -2
  31. package/dist/components/sandbox-terminal.d.ts +29 -0
  32. package/dist/components/sandbox-workspace.d.ts +5 -1
  33. package/dist/components/workspace-dock.d.ts +3 -1
  34. package/dist/composer.d.ts +2 -2
  35. package/dist/composer.js +6 -6
  36. package/dist/hooks/internal.d.ts +6 -0
  37. package/dist/hooks/use-codex-accounts.d.ts +1 -1
  38. package/dist/hooks/use-environments.d.ts +10 -4
  39. package/dist/hooks/use-machines.d.ts +7 -0
  40. package/dist/hooks/use-sandbox-files.d.ts +19 -0
  41. package/dist/hooks/use-sandbox-git.d.ts +7 -0
  42. package/dist/hooks/use-transcription.d.ts +2 -2
  43. package/dist/hooks/use-voice-input.d.ts +13 -4
  44. package/dist/index.d.ts +6 -5
  45. package/dist/index.js +904 -538
  46. package/dist/index.js.map +1 -1
  47. package/dist/lib/composer-responsive-context.d.ts +9 -0
  48. package/dist/lib/use-portal-token-style.d.ts +14 -3
  49. package/dist/machines.js +2 -2
  50. package/dist/{queue-surface-implementation-J4XJFKRW.js → queue-surface-implementation-U5JFG7HT.js} +19 -18
  51. package/dist/queue-surface-implementation-U5JFG7HT.js.map +1 -0
  52. package/dist/realtime/realtime-control.d.ts +9 -0
  53. package/dist/realtime.js +29 -32
  54. package/dist/realtime.js.map +1 -1
  55. package/dist/session-ui.js +5 -5
  56. package/dist/session.js +4 -4
  57. package/dist/timeline/activity-rail.d.ts +3 -2
  58. package/dist/timeline/index.d.ts +2 -2
  59. package/dist/timeline/parsers.d.ts +3 -3
  60. package/dist/timeline/registry.d.ts +3 -0
  61. package/dist/timeline/screenshot-lightbox.d.ts +1 -1
  62. package/dist/timeline/types.d.ts +4 -0
  63. package/dist/voice-recording-store.d.ts +8 -2
  64. package/package.json +20 -4
  65. package/src/client.ts +3 -0
  66. package/src/components/chat-composer.tsx +12 -2
  67. package/src/components/code-editor.tsx +1 -1
  68. package/src/components/command-palette.tsx +1 -1
  69. package/src/components/composer-transcription-control.tsx +41 -16
  70. package/src/components/composer.tsx +52 -26
  71. package/src/components/copy-button.tsx +2 -2
  72. package/src/components/desktop-viewer.tsx +2 -2
  73. package/src/components/file-browser.tsx +39 -17
  74. package/src/components/human-input-form.tsx +2 -2
  75. package/src/components/machine-card.tsx +1 -1
  76. package/src/components/machines/machine-detail.tsx +39 -15
  77. package/src/components/machines-dashboard.tsx +7 -6
  78. package/src/components/message-timeline.tsx +44 -11
  79. package/src/components/model-picker.tsx +2 -2
  80. package/src/components/model-policy-picker.tsx +16 -15
  81. package/src/components/queue-surface-implementation.tsx +20 -16
  82. package/src/components/queue-surface-state.tsx +2 -2
  83. package/src/components/sandbox-files.tsx +140 -60
  84. package/src/components/sandbox-terminal.tsx +24 -5
  85. package/src/components/sandbox-workspace.tsx +180 -142
  86. package/src/components/session-chrome.tsx +14 -14
  87. package/src/components/tooltip.tsx +13 -16
  88. package/src/components/user-message-body.tsx +1 -1
  89. package/src/components/workbench-changes.tsx +4 -4
  90. package/src/components/workspace-dock.tsx +13 -5
  91. package/src/composer.ts +3 -0
  92. package/src/hooks/internal.ts +132 -38
  93. package/src/hooks/use-codex-accounts.ts +4 -4
  94. package/src/hooks/use-environments.ts +24 -5
  95. package/src/hooks/use-file-attachments.ts +70 -32
  96. package/src/hooks/use-goal.ts +17 -5
  97. package/src/hooks/use-machines.ts +47 -1
  98. package/src/hooks/use-sandbox-files.ts +310 -59
  99. package/src/hooks/use-sandbox-git.ts +148 -41
  100. package/src/hooks/use-session-capabilities.ts +35 -24
  101. package/src/hooks/use-session-events.ts +18 -4
  102. package/src/hooks/use-transcription.ts +4 -14
  103. package/src/hooks/use-turn-queue.ts +17 -5
  104. package/src/hooks/use-voice-input.ts +481 -36
  105. package/src/hooks/use-windowed-sections.ts +9 -7
  106. package/src/index.ts +11 -6
  107. package/src/lib/composer-responsive-context.ts +15 -0
  108. package/src/lib/use-portal-token-style.ts +60 -15
  109. package/src/provider.tsx +52 -7
  110. package/src/realtime/dropdown-menu.tsx +15 -19
  111. package/src/realtime/realtime-control.tsx +18 -7
  112. package/src/timeline/activity-rail.tsx +15 -6
  113. package/src/timeline/index.ts +1 -1
  114. package/src/timeline/parsers.ts +33 -20
  115. package/src/timeline/projection.ts +23 -0
  116. package/src/timeline/registry.ts +7 -0
  117. package/src/timeline/screenshot-lightbox.tsx +15 -4
  118. package/src/timeline/shared.tsx +8 -8
  119. package/src/timeline/tool-renderers.tsx +166 -22
  120. package/src/timeline/turn-summary.tsx +6 -6
  121. package/src/timeline/types.ts +4 -0
  122. package/src/voice-recording-store.ts +15 -0
  123. package/styles/compiled.css +5065 -0
  124. package/styles/compiled.d.ts +2 -0
  125. package/styles/effective-tokens.css +27 -0
  126. package/styles/index.css +3 -0
  127. package/styles/responsive.css +196 -0
  128. package/styles/responsive.d.ts +2 -0
  129. package/styles/tokens.css +50 -17
  130. package/dist/chunk-6EDV6YFE.js.map +0 -1
  131. package/dist/chunk-EB67J347.js.map +0 -1
  132. package/dist/chunk-EHSYZ4KP.js.map +0 -1
  133. package/dist/chunk-FD72GJMK.js.map +0 -1
  134. package/dist/chunk-JB5725BI.js +0 -48
  135. package/dist/chunk-JB5725BI.js.map +0 -1
  136. package/dist/chunk-L3EWO3UK.js.map +0 -1
  137. package/dist/chunk-LWR4MXSS.js.map +0 -1
  138. package/dist/chunk-SRFUT2ZU.js.map +0 -1
  139. package/dist/chunk-U3CSCMWA.js.map +0 -1
  140. package/dist/queue-surface-implementation-J4XJFKRW.js.map +0 -1
  141. /package/dist/{chunk-HFO4ERGQ.js.map → chunk-FWXGSNXT.js.map} +0 -0
@@ -5,12 +5,13 @@ import type {
5
5
  FsWriteResponse,
6
6
  GitChangedPayload,
7
7
  GitFileStatusCode,
8
+ GitStatusResponse,
8
9
  OpenGeniRequestOptions,
9
10
  SessionEvent,
10
11
  WorkspaceCaptureManifest,
11
12
  } from "@opengeni/sdk";
12
- import { useCallback, useEffect, useRef, useState } from "react";
13
- import { useOpenGeni, type ClientOverride } from "../provider";
13
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
14
+ import { type ClientOverride, useOpenGeni } from "../provider";
14
15
 
15
16
  /** The git-status overlay a file row may carry (tints modified files in the tree). */
16
17
  export type FileTreeStatus = "added" | "modified" | "deleted" | "renamed" | "untracked";
@@ -82,6 +83,10 @@ export type UseSandboxFilesOptions = ClientOverride & {
82
83
  rootPath?: string | undefined;
83
84
  /** Hold off the initial list (e.g. panel collapsed). Default true. */
84
85
  enabled?: boolean | undefined;
86
+ /** Pause live reads/invalidation while preserving the last rendered result. */
87
+ active?: boolean | undefined;
88
+ /** Repository roots advertised by capabilities, relative to the workspace. */
89
+ repoPaths?: readonly string[] | undefined;
85
90
  /** The lease liveness ("cold" | "warm" | "draining"). The structured FileSystem
86
91
  * capability is advertised even on a COLD box, so the mount-time list can race
87
92
  * the box: it lists before the box is warm, gets an empty/errored result, and
@@ -127,15 +132,58 @@ export type UseSandboxFilesResult = {
127
132
  moveEntry: (path: string, newPath: string, opts?: { overwrite?: boolean }) => Promise<void>;
128
133
  /** Re-list the whole tree from the root. */
129
134
  refresh: () => Promise<void>;
135
+ /** Monotonic invalidation token for open file previews. Advances when an
136
+ * external filesystem change or completed agent tool may have changed bytes. */
137
+ contentRevision?: number | undefined;
130
138
  /** Which source the tree is currently served from: the live box, the turn-end
131
139
  * capture (cold/offline), or neither yet. M5's source badge reads this. */
132
140
  source: "live" | "capture" | null;
133
141
  /** When the served capture was taken (ISO), when `source === "capture"`. */
134
142
  capturedAt: string | null;
143
+ /** Lightweight repository context loaded by the same status request that tints files. */
144
+ gitSummary?: SandboxFilesGitSummary | null | undefined;
145
+ /** True only while the initial repository-status overlay is still loading. */
146
+ gitLoading?: boolean | undefined;
135
147
  loading: boolean;
136
148
  error: Error | null;
137
149
  };
138
150
 
151
+ export type SandboxFilesGitSummary = {
152
+ branch: string | null;
153
+ isRepo: boolean;
154
+ repoCount: number;
155
+ ahead: number;
156
+ behind: number;
157
+ dirtyCount: number;
158
+ };
159
+
160
+ function normalizeRoot(root: string): string {
161
+ const normalized = root
162
+ .replaceAll("\\", "/")
163
+ .replace(/^\.\/+/, "")
164
+ .replace(/\/+$/, "");
165
+ return normalized === "." ? "" : normalized;
166
+ }
167
+
168
+ function qualifyStatusPath(root: string, path: string): string {
169
+ const normalized = normalizeRoot(root);
170
+ return normalized ? `${normalized}/${path}` : path;
171
+ }
172
+
173
+ function summarizeStatuses(
174
+ statuses: Array<{ root: string; status: GitStatusResponse }>,
175
+ ): SandboxFilesGitSummary {
176
+ const repositories = statuses.filter(({ status }) => status.isRepo);
177
+ return {
178
+ branch: repositories.length === 1 ? (repositories[0]?.status.head ?? null) : null,
179
+ isRepo: repositories.length > 0,
180
+ repoCount: repositories.length,
181
+ ahead: repositories.reduce((sum, { status }) => sum + status.ahead, 0),
182
+ behind: repositories.reduce((sum, { status }) => sum + status.behind, 0),
183
+ dirtyCount: repositories.reduce((sum, { status }) => sum + status.files.length, 0),
184
+ };
185
+ }
186
+
139
187
  /** The workspace-relative parent directory of a POSIX path ("" for a root entry). */
140
188
  function parentOf(path: string): string {
141
189
  const i = path.lastIndexOf("/");
@@ -209,7 +257,10 @@ function replaceChildren(
209
257
  return { ...node, children };
210
258
  }
211
259
  if (node.kind === "dir" && node.children && targetPath.startsWith(`${node.path}/`)) {
212
- return { ...node, children: replaceChildren(node.children, targetPath, children) };
260
+ return {
261
+ ...node,
262
+ children: replaceChildren(node.children, targetPath, children),
263
+ };
213
264
  }
214
265
  return node;
215
266
  });
@@ -227,6 +278,42 @@ function findNodeByPath(nodes: FileTreeNode[], path: string): FileTreeNode | und
227
278
  return undefined;
228
279
  }
229
280
 
281
+ /** Root plus every directory whose children are currently loaded. Re-listing
282
+ * this small visible frontier refreshes agent shell edits without expanding or
283
+ * traversing hidden directories. */
284
+ function loadedDirectoryPaths(nodes: FileTreeNode[]): string[] {
285
+ const paths = [""];
286
+ const walk = (list: FileTreeNode[]) => {
287
+ for (const node of list) {
288
+ if (node.kind !== "dir" || node.children === undefined) continue;
289
+ paths.push(node.path);
290
+ walk(node.children);
291
+ }
292
+ };
293
+ walk(nodes);
294
+ return paths;
295
+ }
296
+
297
+ /** Repository paths are capability-advertised and therefore safe to use as a
298
+ * bounded initial lazy-tree frontier. Listing their ancestors concurrently with
299
+ * the root avoids a root → repositories → repo remote waterfall. */
300
+ function initialDirectoryFrontier(rootPath: string, repoPaths: readonly string[]): string[] {
301
+ const rootParts = normalizeRoot(rootPath).split("/").filter(Boolean);
302
+ const paths = new Set<string>();
303
+ for (const repoPath of repoPaths) {
304
+ const parts = normalizeRoot(repoPath).split("/").filter(Boolean);
305
+ if (rootParts.some((part, index) => parts[index] !== part)) continue;
306
+ for (let depth = rootParts.length + 1; depth <= parts.length; depth += 1) {
307
+ paths.add(parts.slice(0, depth).join("/"));
308
+ }
309
+ }
310
+ // A workspace with hundreds of repositories should remain lazy. This still
311
+ // preloads the shared repository container and the first visible roots.
312
+ return [...paths]
313
+ .sort((a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b))
314
+ .slice(0, 8);
315
+ }
316
+
230
317
  // ── In-place (optimistic) tree mutations ────────────────────────────────────
231
318
  // The Pierre mutation-handle analogue. These splice a single node in/out/across
232
319
  // the immutable tree, PRESERVING every other node's `children` (and therefore the
@@ -279,7 +366,10 @@ function removeNode(nodes: FileTreeNode[], path: string): FileTreeNode[] {
279
366
  return nodes.map((node) => {
280
367
  if (node.path === parent) {
281
368
  if (node.kind !== "dir" || node.children === undefined) return node;
282
- return { ...node, children: node.children.filter((n) => n.path !== path) };
369
+ return {
370
+ ...node,
371
+ children: node.children.filter((n) => n.path !== path),
372
+ };
283
373
  }
284
374
  if (node.kind === "dir" && node.children && parent.startsWith(`${node.path}/`)) {
285
375
  return { ...node, children: removeNode(node.children, path) };
@@ -390,10 +480,15 @@ export function useSandboxFiles(
390
480
  ): UseSandboxFilesResult {
391
481
  const { client, workspaceId } = useOpenGeni(options);
392
482
  const enabled = (options.enabled ?? true) && Boolean(sessionId);
483
+ const active = options.active ?? true;
393
484
  const rootPath = options.rootPath ?? "";
485
+ const repoPathsKey = [
486
+ ...new Set((options.repoPaths?.length ? options.repoPaths : [rootPath]).map(normalizeRoot)),
487
+ ].join("\u0000");
488
+ const repoPaths = useMemo(() => repoPathsKey.split("\u0000"), [repoPathsKey]);
394
489
  const capture = options.capture ?? null;
395
490
  const isLive = options.liveness === "warm" || options.liveness === "draining";
396
- const identityKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${rootPath}`;
491
+ const identityKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${rootPath}\u0000${repoPathsKey}`;
397
492
 
398
493
  const [tree, setTree] = useState<FileTreeNode[]>([]);
399
494
  // A ref mirror of the current tree — lets the optimistic path snapshot the
@@ -404,6 +499,9 @@ export function useSandboxFiles(
404
499
  const [loading, setLoading] = useState(false);
405
500
  const [expandingPaths, setExpandingPaths] = useState<Set<string>>(new Set());
406
501
  const [error, setError] = useState<Error | null>(null);
502
+ const [gitSummary, setGitSummary] = useState<SandboxFilesGitSummary | null>(null);
503
+ const [gitLoading, setGitLoading] = useState(false);
504
+ const [contentRevision, setContentRevision] = useState(0);
407
505
  const [stateIdentity, setStateIdentity] = useState(identityKey);
408
506
  // Which source the tree currently reflects — "capture" (cold paint) or "live".
409
507
  const [source, setSource] = useState<"live" | "capture" | null>(null);
@@ -418,7 +516,9 @@ export function useSandboxFiles(
418
516
  const lastSeqRef = useRef(0);
419
517
  const pendingParentsRef = useRef<Set<string>>(new Set());
420
518
  const pendingGitRef = useRef(false);
519
+ const pendingAgentToolRef = useRef(false);
421
520
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
521
+ const lastCommandRefreshAtRef = useRef(0);
422
522
 
423
523
  // ── Self-emitted fs.changed de-dupe ───────────────────────────────────────
424
524
  // Every one of OUR mutations emits an `fs.changed` event back on the live log
@@ -470,55 +570,97 @@ export function useSandboxFiles(
470
570
  refreshAbortRef.current = refreshAbort;
471
571
  const generation = (refreshGenerationRef.current += 1);
472
572
  setLoading(true);
573
+ setGitLoading(true);
473
574
  setError(null);
474
575
  try {
475
- // Pull the git-status overlay first (best-effort a non-repo box just
476
- // returns isRepo:false), then the tree, so the first paint is tinted.
477
- try {
478
- const status = await client.gitStatus(
479
- workspaceId,
480
- sessionId,
481
- { path: rootPath },
482
- { signal: refreshAbort.signal },
483
- );
484
- if (refreshGenerationRef.current !== generation) return;
485
- const overlay = new Map<string, FileTreeStatus>();
486
- for (const file of status.files) {
487
- const code = file.worktree ?? file.index;
488
- const mapped = code ? GIT_STATUS_TO_TREE[code] : undefined;
489
- if (mapped) overlay.set(file.path, mapped);
490
- }
491
- statusRef.current = overlay;
492
- } catch {
493
- if (refreshGenerationRef.current !== generation) return;
494
- statusRef.current = new Map();
495
- }
496
- const listed = await client.fsList(
576
+ // Start the optional Git tint and the actual directory listing together.
577
+ // A remote status read must never block the first useful file-tree paint.
578
+ const frontierPaths = initialDirectoryFrontier(rootPath, repoPaths);
579
+ const listRequests = [rootPath, ...frontierPaths].map((path) => ({
580
+ path,
581
+ depth: 1,
582
+ }));
583
+ const statusPromise =
584
+ repoPaths.length > 0
585
+ ? client
586
+ .gitReadBatch(
587
+ workspaceId,
588
+ sessionId,
589
+ {
590
+ requests: repoPaths.map((root) => ({
591
+ status: { path: root },
592
+ })),
593
+ },
594
+ { signal: refreshAbort.signal },
595
+ )
596
+ .then((batch) =>
597
+ batch.results.map((result, index) => ({
598
+ root: repoPaths[index] ?? "",
599
+ status: result.status,
600
+ })),
601
+ )
602
+ .catch(() => null)
603
+ : Promise.resolve([]);
604
+ // Root plus the bounded repository frontier share one API request, one
605
+ // direct holder, and one provider attach. The old per-directory fan-out
606
+ // paid that fixed control-plane cost up to nine times on every Files open.
607
+ const listedBatch = await client.fsListBatch(
497
608
  workspaceId,
498
609
  sessionId,
499
- { path: rootPath, depth: 1 },
610
+ { requests: listRequests },
500
611
  { signal: refreshAbort.signal },
501
612
  );
502
613
  if (refreshGenerationRef.current !== generation) return;
614
+ const listed = listedBatch.results[0];
615
+ if (!listed) throw new Error("Workspace list batch did not return the root result.");
503
616
  const children = (listed.root.children ?? []).map((node) => fsNodeToTree(node));
504
- // Merge rather than replace so an explicit refresh / cold→warm re-list folds
505
- // in new entries WITHOUT collapsing the dirs the user already expanded (and,
506
- // via the identity-preserving merge, WITHOUT remounting unchanged rows — the
507
- // no-flicker cold→warm reconcile). On a first (empty) load this is a plain set.
508
- setTree((prev) =>
509
- applyStatus(prev.length === 0 ? children : mergeRootChildren(prev, children)),
510
- );
511
- // Live data is now serving — flip the source off the capture snapshot.
617
+ // Paint the root and already-returned frontier in one commit so preloaded
618
+ // repository folders never flicker through a collapsed intermediate tree.
619
+ setTree((prev) => {
620
+ let hydrated = prev.length === 0 ? children : mergeRootChildren(prev, children);
621
+ for (const [index, path] of frontierPaths.entries()) {
622
+ const prefetched = listedBatch.results[index + 1];
623
+ if (!prefetched) continue;
624
+ hydrated = replaceChildren(
625
+ hydrated,
626
+ path,
627
+ (prefetched.root.children ?? []).map((node) => fsNodeToTree(node)),
628
+ );
629
+ }
630
+ return applyStatus(hydrated);
631
+ });
512
632
  sourceRef.current = "live";
513
633
  setSource("live");
634
+ setLoading(false);
635
+
636
+ const statuses = await statusPromise;
637
+ if (refreshGenerationRef.current !== generation) return;
638
+ if (statuses) {
639
+ const overlay = new Map<string, FileTreeStatus>();
640
+ for (const { root, status } of statuses) {
641
+ for (const file of status.files) {
642
+ const code = file.worktree ?? file.index;
643
+ const mapped = code ? GIT_STATUS_TO_TREE[code] : undefined;
644
+ if (mapped) overlay.set(qualifyStatusPath(root, file.path), mapped);
645
+ }
646
+ }
647
+ statusRef.current = overlay;
648
+ setGitSummary(summarizeStatuses(statuses));
649
+ } else {
650
+ statusRef.current = new Map();
651
+ setGitSummary(null);
652
+ }
653
+ setGitLoading(false);
654
+ setTree((prev) => applyStatus(prev));
514
655
  } catch (cause) {
515
656
  if (refreshGenerationRef.current !== generation) return;
516
657
  setError(cause instanceof Error ? cause : new Error(String(cause)));
517
658
  } finally {
518
659
  if (refreshGenerationRef.current === generation) setLoading(false);
660
+ if (refreshGenerationRef.current === generation) setGitLoading(false);
519
661
  if (refreshAbortRef.current === refreshAbort) refreshAbortRef.current = null;
520
662
  }
521
- }, [client, workspaceId, sessionId, rootPath, applyStatus]);
663
+ }, [client, workspaceId, sessionId, rootPath, repoPaths, applyStatus]);
522
664
 
523
665
  // Seed (or re-seed) the tree from a turn-end capture — the COLD/offline paint,
524
666
  // zero Channel-A calls. The tree index is workspace-relative (`treeIndex`); the
@@ -543,6 +685,15 @@ export function useSandboxFiles(
543
685
  );
544
686
  sourceRef.current = "capture";
545
687
  setSource("capture");
688
+ setGitSummary({
689
+ branch: manifest.repos.length === 1 ? (manifest.repos[0]?.head ?? null) : null,
690
+ isRepo: manifest.repos.length > 0,
691
+ repoCount: manifest.repos.length,
692
+ ahead: manifest.repos.reduce((sum, repo) => sum + repo.ahead, 0),
693
+ behind: manifest.repos.reduce((sum, repo) => sum + repo.behind, 0),
694
+ dirtyCount: manifest.files.length,
695
+ });
696
+ setGitLoading(false);
546
697
  setError(null);
547
698
  },
548
699
  [applyStatus],
@@ -795,7 +946,12 @@ export function useSandboxFiles(
795
946
  // exist in a loaded parent (an editor SAVE to an existing file mutates no
796
947
  // tree shape — just content — so it needs no optimistic node, no reconcile).
797
948
  const exists = Boolean(findNodeByPath(tree, path));
798
- const node: FileTreeNode = { path, name: leafOf(path), kind: "file", size: content.length };
949
+ const node: FileTreeNode = {
950
+ path,
951
+ name: leafOf(path),
952
+ kind: "file",
953
+ size: content.length,
954
+ };
799
955
  return await runOptimistic(
800
956
  "write",
801
957
  (nodes) => (exists ? nodes : insertNode(nodes, parent, node)),
@@ -835,11 +991,21 @@ export function useSandboxFiles(
835
991
  async (path: string): Promise<void> => {
836
992
  if (!sessionId) throw new Error("no session");
837
993
  const parent = parentOf(path);
838
- const node: FileTreeNode = { path, name: leafOf(path), kind: "file", size: 0 };
994
+ const node: FileTreeNode = {
995
+ path,
996
+ name: leafOf(path),
997
+ kind: "file",
998
+ size: 0,
999
+ };
839
1000
  await runOptimistic(
840
1001
  "create file",
841
1002
  (nodes) => insertNode(nodes, parent, node),
842
- () => client.fsWrite(workspaceId, sessionId, { path, content: "", overwrite: false }),
1003
+ () =>
1004
+ client.fsWrite(workspaceId, sessionId, {
1005
+ path,
1006
+ content: "",
1007
+ overwrite: false,
1008
+ }),
843
1009
  [parent],
844
1010
  );
845
1011
  },
@@ -852,7 +1018,12 @@ export function useSandboxFiles(
852
1018
  const parent = parentOf(path);
853
1019
  // A freshly-created dir is empty + expanded: children:[] (not undefined, so
854
1020
  // it doesn't show the lazy-expand marker over a dir we KNOW is empty).
855
- const node: FileTreeNode = { path, name: leafOf(path), kind: "dir", children: [] };
1021
+ const node: FileTreeNode = {
1022
+ path,
1023
+ name: leafOf(path),
1024
+ kind: "dir",
1025
+ children: [],
1026
+ };
856
1027
  await runOptimistic(
857
1028
  "create folder",
858
1029
  (nodes) => insertNode(nodes, parent, node),
@@ -930,6 +1101,8 @@ export function useSandboxFiles(
930
1101
  lastSeqRef.current = 0;
931
1102
  pendingParentsRef.current = new Set();
932
1103
  pendingGitRef.current = false;
1104
+ pendingAgentToolRef.current = false;
1105
+ lastCommandRefreshAtRef.current = 0;
933
1106
  if (debounceRef.current) {
934
1107
  clearTimeout(debounceRef.current);
935
1108
  debounceRef.current = null;
@@ -944,6 +1117,9 @@ export function useSandboxFiles(
944
1117
  setSource(null);
945
1118
  setLoading(false);
946
1119
  setError(null);
1120
+ setGitSummary(null);
1121
+ setGitLoading(false);
1122
+ setContentRevision(0);
947
1123
  }
948
1124
  if (!enabled) {
949
1125
  return;
@@ -952,6 +1128,13 @@ export function useSandboxFiles(
952
1128
  if (currentCapture && (identityChanged || !isLive || sourceRef.current !== "live")) {
953
1129
  seedFromCapture(currentCapture);
954
1130
  }
1131
+ if (!active) {
1132
+ if (!active) {
1133
+ setLoading(false);
1134
+ setGitLoading(false);
1135
+ }
1136
+ return;
1137
+ }
955
1138
  if (isLive || !currentCapture) {
956
1139
  void refresh();
957
1140
  }
@@ -960,7 +1143,7 @@ export function useSandboxFiles(
960
1143
  refreshAbortRef.current = null;
961
1144
  refreshGenerationRef.current += 1;
962
1145
  };
963
- }, [enabled, isLive, captureRevision, identityKey, refresh, seedFromCapture]);
1146
+ }, [enabled, active, isLive, captureRevision, identityKey, refresh, seedFromCapture]);
964
1147
 
965
1148
  // Re-pull JUST the git-status overlay and re-tint the existing tree in place —
966
1149
  // no fs re-list, no collapse. This is all a `git.changed` (commit/stage/checkout)
@@ -969,26 +1152,36 @@ export function useSandboxFiles(
969
1152
  if (!sessionId) return;
970
1153
  const identityGeneration = identityGenerationRef.current;
971
1154
  const identitySignal = identityAbortRef.current.signal;
1155
+ setGitLoading(true);
972
1156
  try {
973
- const status = await client.gitStatus(
1157
+ const batch = await client.gitReadBatch(
974
1158
  workspaceId,
975
1159
  sessionId,
976
- { path: rootPath },
1160
+ { requests: repoPaths.map((root) => ({ status: { path: root } })) },
977
1161
  { signal: identitySignal },
978
1162
  );
1163
+ const statuses = batch.results.map((result, index) => ({
1164
+ root: repoPaths[index] ?? "",
1165
+ status: result.status,
1166
+ }));
979
1167
  if (identityGenerationRef.current !== identityGeneration) return;
980
1168
  const overlay = new Map<string, FileTreeStatus>();
981
- for (const file of status.files) {
982
- const code = file.worktree ?? file.index;
983
- const mapped = code ? GIT_STATUS_TO_TREE[code] : undefined;
984
- if (mapped) overlay.set(file.path, mapped);
1169
+ for (const { root, status } of statuses) {
1170
+ for (const file of status.files) {
1171
+ const code = file.worktree ?? file.index;
1172
+ const mapped = code ? GIT_STATUS_TO_TREE[code] : undefined;
1173
+ if (mapped) overlay.set(qualifyStatusPath(root, file.path), mapped);
1174
+ }
985
1175
  }
986
1176
  statusRef.current = overlay;
1177
+ setGitSummary(summarizeStatuses(statuses));
987
1178
  setTree((prev) => applyStatus(prev));
988
1179
  } catch {
989
1180
  /* a non-repo box has no overlay — leave the tree untinted */
1181
+ } finally {
1182
+ if (identityGenerationRef.current === identityGeneration) setGitLoading(false);
990
1183
  }
991
- }, [client, workspaceId, sessionId, rootPath, applyStatus]);
1184
+ }, [client, workspaceId, sessionId, repoPaths, applyStatus]);
992
1185
 
993
1186
  // Auto-reconcile on fs/git change notifications — TARGETED, never a root
994
1187
  // collapse-reload, and de-duped against our OWN mutations.
@@ -1004,7 +1197,18 @@ export function useSandboxFiles(
1004
1197
 
1005
1198
  useEffect(() => {
1006
1199
  if (!enabled || !events) return;
1200
+ // An inactive tab must not replay the entire historical event log when it is
1201
+ // opened. Keep its cursor current without performing any reconciliation;
1202
+ // only events that arrive while Files is visible should trigger live reads.
1203
+ if (!active) {
1204
+ for (const event of events) {
1205
+ if (event.sequence > lastSeqRef.current) lastSeqRef.current = event.sequence;
1206
+ }
1207
+ return;
1208
+ }
1007
1209
  let sawNew = false;
1210
+ let contentChanged = false;
1211
+ let sawCommandDelta = false;
1008
1212
  for (const event of events) {
1009
1213
  if (event.sequence <= lastSeqRef.current) continue;
1010
1214
  if (event.type === "fs.changed") {
@@ -1015,6 +1219,7 @@ export function useSandboxFiles(
1015
1219
  if (payload.source === "write") continue;
1016
1220
  if (typeof payload.revision === "number" && ownRevisionsRef.current.has(payload.revision))
1017
1221
  continue;
1222
+ contentChanged = true;
1018
1223
  for (const change of payload.changes ?? []) {
1019
1224
  pendingParentsRef.current.add(parentOf(change.path));
1020
1225
  if (change.oldPath) pendingParentsRef.current.add(parentOf(change.oldPath));
@@ -1033,34 +1238,77 @@ export function useSandboxFiles(
1033
1238
  )
1034
1239
  continue;
1035
1240
  pendingGitRef.current = true;
1241
+ } else if (event.type === "agent.toolCall.output") {
1242
+ // Agent shell/apply-patch writes do not necessarily pass through the
1243
+ // structured FS service, so they may have no fs.changed notification.
1244
+ // A completed tool is a bounded invalidation point: refresh only the
1245
+ // currently-loaded directory frontier, never poll and never traverse a
1246
+ // collapsed subtree.
1247
+ sawNew = true;
1248
+ contentChanged = true;
1249
+ pendingAgentToolRef.current = true;
1250
+ // Shell/apply-patch writes bypass the structured Git service too, so no
1251
+ // git.changed event is guaranteed. Refresh the summary/tints alongside
1252
+ // the visible tree frontier; both remote reads can run concurrently.
1253
+ pendingGitRef.current = true;
1254
+ } else if (event.type === "sandbox.command.output.delta") {
1255
+ // Ordinary shell writes can bypass Channel-A and therefore emit no
1256
+ // fs.changed/git.changed event until the command completes. Reconcile
1257
+ // the visible frontier at most once per second while output streams so
1258
+ // Files and Changes stay useful without turning the dock into polling.
1259
+ sawNew = true;
1260
+ contentChanged = true;
1261
+ sawCommandDelta = true;
1262
+ pendingAgentToolRef.current = true;
1263
+ pendingGitRef.current = true;
1036
1264
  }
1037
1265
  }
1038
1266
  // Advance the high-water mark past everything we've folded.
1039
1267
  for (const event of events)
1040
1268
  if (event.sequence > lastSeqRef.current) lastSeqRef.current = event.sequence;
1041
1269
  if (!sawNew) return;
1270
+ if (contentChanged && !sawCommandDelta) setContentRevision((revision) => revision + 1);
1042
1271
 
1043
1272
  if (debounceRef.current) clearTimeout(debounceRef.current);
1044
1273
  const identityGeneration = identityGenerationRef.current;
1274
+ const delay = sawCommandDelta
1275
+ ? Math.max(0, 1_000 - (Date.now() - lastCommandRefreshAtRef.current))
1276
+ : 150;
1045
1277
  debounceRef.current = setTimeout(() => {
1046
1278
  debounceRef.current = null;
1047
1279
  if (identityGenerationRef.current !== identityGeneration) return;
1280
+ if (sawCommandDelta) {
1281
+ lastCommandRefreshAtRef.current = Date.now();
1282
+ setContentRevision((revision) => revision + 1);
1283
+ }
1048
1284
  const parents = pendingParentsRef.current;
1049
1285
  pendingParentsRef.current = new Set();
1050
1286
  const wantGit = pendingGitRef.current;
1051
1287
  pendingGitRef.current = false;
1052
- // Reconcile the changed directories (in place). A git change re-tints first
1053
- // so the freshly-listed nodes get the correct overlay.
1288
+ const wantAgentTool = pendingAgentToolRef.current;
1289
+ pendingAgentToolRef.current = false;
1290
+ // Reconcile the changed directories and Git overlay concurrently. Both are
1291
+ // independent remote reads; applyStatus makes their completion order safe.
1054
1292
  void (async () => {
1055
- if (wantGit) await refreshGitOverlay();
1056
- if (identityGenerationRef.current !== identityGeneration) return;
1057
- for (const parent of parents) {
1058
- if (identityGenerationRef.current !== identityGeneration) return;
1059
- await reconcilePath(parent);
1060
- }
1293
+ const paths = wantAgentTool ? loadedDirectoryPaths(treeRef.current) : [...parents];
1294
+ await Promise.all([
1295
+ wantGit ? refreshGitOverlay() : Promise.resolve(),
1296
+ Promise.all(paths.map((path) => reconcilePath(path))),
1297
+ ]);
1061
1298
  })();
1062
- }, 150);
1063
- }, [enabled, events, reconcilePath, refreshGitOverlay]);
1299
+ }, delay);
1300
+ }, [enabled, active, events, reconcilePath, refreshGitOverlay]);
1301
+
1302
+ useEffect(() => {
1303
+ if (active) return;
1304
+ if (debounceRef.current) {
1305
+ clearTimeout(debounceRef.current);
1306
+ debounceRef.current = null;
1307
+ }
1308
+ pendingParentsRef.current = new Set();
1309
+ pendingGitRef.current = false;
1310
+ pendingAgentToolRef.current = false;
1311
+ }, [active]);
1064
1312
 
1065
1313
  useEffect(
1066
1314
  () => () => {
@@ -1084,8 +1332,11 @@ export function useSandboxFiles(
1084
1332
  deleteEntry,
1085
1333
  moveEntry,
1086
1334
  refresh,
1335
+ contentRevision: identityMatches ? contentRevision : 0,
1087
1336
  source: identityMatches ? source : null,
1088
1337
  capturedAt: identityMatches && source === "capture" ? (capture?.capturedAt ?? null) : null,
1338
+ gitSummary: identityMatches ? gitSummary : null,
1339
+ gitLoading: identityMatches && gitLoading,
1089
1340
  loading: identityMatches && loading,
1090
1341
  error: identityMatches ? error : null,
1091
1342
  };