@opengeni/react 0.3.1 → 0.4.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 (40) hide show
  1. package/dist/index.d.ts +1035 -14
  2. package/dist/index.js +6867 -1884
  3. package/dist/index.js.map +1 -1
  4. package/package.json +65 -2
  5. package/src/client.ts +21 -0
  6. package/src/components/code-editor.tsx +398 -0
  7. package/src/components/desktop-viewer.tsx +647 -0
  8. package/src/components/diff-view.tsx +230 -0
  9. package/src/components/file-browser.tsx +838 -0
  10. package/src/components/message-timeline.tsx +70 -196
  11. package/src/components/pierre-diff.tsx +140 -0
  12. package/src/components/pierre-file.tsx +142 -0
  13. package/src/components/sandbox-files.tsx +509 -0
  14. package/src/components/sandbox-terminal.tsx +425 -0
  15. package/src/components/workspace-dock.tsx +247 -0
  16. package/src/hooks/use-desktop-stream.ts +214 -0
  17. package/src/hooks/use-sandbox-files.ts +670 -0
  18. package/src/hooks/use-sandbox-git.ts +105 -0
  19. package/src/hooks/use-sandbox-terminal.ts +226 -0
  20. package/src/hooks/use-session-capabilities.ts +415 -0
  21. package/src/hooks/use-terminal-stream.ts +207 -0
  22. package/src/index.ts +111 -2
  23. package/src/lib/cn.ts +20 -1
  24. package/src/lib/git-patch.ts +37 -0
  25. package/src/lib/use-theme-type.ts +40 -0
  26. package/src/lib/xterm-theme.ts +34 -0
  27. package/src/timeline/activity-rail.tsx +207 -0
  28. package/src/timeline/disclosure-context.tsx +34 -0
  29. package/src/timeline/index.ts +85 -0
  30. package/src/timeline/parsers.ts +248 -0
  31. package/src/{timeline.ts → timeline/projection.ts} +59 -134
  32. package/src/timeline/registry.ts +96 -0
  33. package/src/timeline/screenshot-lightbox.tsx +152 -0
  34. package/src/timeline/shared.tsx +481 -0
  35. package/src/timeline/tool-diff.tsx +91 -0
  36. package/src/timeline/tool-renderers.tsx +882 -0
  37. package/src/timeline/turn-summary.tsx +125 -0
  38. package/src/timeline/types.ts +131 -0
  39. package/src/types/external.d.ts +7 -0
  40. package/styles/index.css +72 -0
@@ -0,0 +1,670 @@
1
+ import type {
2
+ FsChangedPayload,
3
+ FsReadResponse,
4
+ FsTreeNode,
5
+ FsWriteResponse,
6
+ GitChangedPayload,
7
+ GitFileStatusCode,
8
+ SessionEvent,
9
+ } from "@opengeni/sdk";
10
+ import { useCallback, useEffect, useRef, useState } from "react";
11
+ import { useOpenGeni, type ClientOverride } from "../provider";
12
+
13
+ /** The git-status overlay a file row may carry (tints modified files in the tree). */
14
+ export type FileTreeStatus = "added" | "modified" | "deleted" | "renamed" | "untracked";
15
+
16
+ /** A node in the Pierre file tree. `children === undefined` ⇒ an unexpanded dir
17
+ * (lazy treeMode); `children: []` ⇒ an expanded-but-empty dir. */
18
+ export type FileTreeNode = {
19
+ path: string; // workspace-relative POSIX
20
+ name: string;
21
+ kind: "file" | "dir";
22
+ children?: FileTreeNode[] | undefined;
23
+ size?: number | null | undefined;
24
+ status?: FileTreeStatus | undefined;
25
+ };
26
+
27
+ export type UseSandboxFilesOptions = ClientOverride & {
28
+ /** Live event log (usually `useSessionEvents().events`) — drives auto-refresh
29
+ * on `fs.changed` / `git.changed`. */
30
+ events?: SessionEvent[] | undefined;
31
+ /** Initial path to list (workspace root by default). */
32
+ rootPath?: string | undefined;
33
+ /** Hold off the initial list (e.g. panel collapsed). Default true. */
34
+ enabled?: boolean | undefined;
35
+ /** The lease liveness ("cold" | "warm" | "draining"). The structured FileSystem
36
+ * capability is advertised even on a COLD box, so the mount-time list can race
37
+ * the box: it lists before the box is warm, gets an empty/errored result, and
38
+ * (with no `fs.changed` event) never re-lists. Passing liveness re-lists when
39
+ * the box first becomes warm, so the tree populates as soon as the box is up. */
40
+ liveness?: string | undefined;
41
+ /** Called when an OPTIMISTIC mutation is reverted because its background
42
+ * Channel-A op failed (e.g. a 409 rename collision). The host wires this to a
43
+ * toast — the tree silently rolls the node back, the user sees why. */
44
+ onMutationError?: ((error: Error, op: string) => void) | undefined;
45
+ };
46
+
47
+ export type UseSandboxFilesResult = {
48
+ /** The tree roots (the listed root's children). */
49
+ tree: FileTreeNode[];
50
+ /** Lazy-expand a directory node in place (lists its immediate children). */
51
+ expand: (path: string) => Promise<void>;
52
+ /** Paths whose lazy `fs.list` is currently in flight — the FileBrowser shows a
53
+ * spinner on these nodes so a 2-3s Channel-A list never looks frozen. */
54
+ expandingPaths: Set<string>;
55
+ /** Read a file for the preview pane (text or base64-for-binary, size-capped). */
56
+ readFile: (path: string) => Promise<FsReadResponse>;
57
+ /** Write a file (overwrite, last-writer-wins) — the editor save path.
58
+ * Optimistic: a brand-new file is spliced into the tree immediately and the
59
+ * Channel-A write runs in the background; on failure the splice is reverted. */
60
+ writeFile: (path: string, content: string) => Promise<FsWriteResponse>;
61
+ /** Create a new empty file (refuses to clobber an existing path: overwrite=false). */
62
+ createFile: (path: string) => Promise<void>;
63
+ /** Create a directory (recursive by default). */
64
+ createDir: (path: string) => Promise<void>;
65
+ /** Delete a path (pass recursive=true for a non-empty directory). */
66
+ deleteEntry: (path: string, recursive?: boolean) => Promise<void>;
67
+ /** Move / rename a path (rename == move). Refuses to clobber unless overwrite=true. */
68
+ moveEntry: (path: string, newPath: string, opts?: { overwrite?: boolean }) => Promise<void>;
69
+ /** Re-list the whole tree from the root. */
70
+ refresh: () => Promise<void>;
71
+ loading: boolean;
72
+ error: Error | null;
73
+ };
74
+
75
+ /** The workspace-relative parent directory of a POSIX path ("" for a root entry). */
76
+ function parentOf(path: string): string {
77
+ const i = path.lastIndexOf("/");
78
+ return i <= 0 ? "" : path.slice(0, i);
79
+ }
80
+
81
+ /** The leaf name of a POSIX path. */
82
+ function leafOf(path: string): string {
83
+ const i = path.lastIndexOf("/");
84
+ return i < 0 ? path : path.slice(i + 1);
85
+ }
86
+
87
+ /** 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
+ /** Stable sibling ordering: dirs before files, then case-insensitive by name —
93
+ * matches a typical depth-1 list so an optimistic insert lands where a real
94
+ * re-list would put it (no jump when the server reconciles). */
95
+ function sortNodes(nodes: FileTreeNode[]): FileTreeNode[] {
96
+ return [...nodes].sort((a, b) => {
97
+ if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1;
98
+ return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
99
+ });
100
+ }
101
+
102
+ function fsNodeToTree(node: FsTreeNode): FileTreeNode {
103
+ const kind = node.type === "dir" ? "dir" : "file";
104
+ // Lazy-tree contract: a depth-bounded `fsList` returns each directory at the
105
+ // depth boundary with `children: []` (the dir is listed, but its grandchildren
106
+ // are NOT). An empty array therefore means "not yet expanded", NOT "empty
107
+ // directory" — so we must map it to `undefined` (the unexpanded marker the
108
+ // FileBrowser keys lazy-expand on). If we kept `[]`, `toggle()`'s
109
+ // `node.children === undefined` guard would never fire and clicking a folder
110
+ // would do nothing (the reported bug). A directory we actually expand has its
111
+ // children spliced in by `replaceChildren` (bypassing this mapper), so a
112
+ // genuinely-empty dir correctly ends up as `[]` AFTER expansion.
113
+ const mappedChildren =
114
+ node.children && node.children.length > 0 ? node.children.map(fsNodeToTree) : undefined;
115
+ return {
116
+ path: node.path,
117
+ name: node.name,
118
+ kind,
119
+ size: node.sizeBytes,
120
+ ...(kind === "dir" ? { children: mappedChildren } : {}),
121
+ };
122
+ }
123
+
124
+ const GIT_STATUS_TO_TREE: Partial<Record<GitFileStatusCode, FileTreeStatus>> = {
125
+ added: "added",
126
+ modified: "modified",
127
+ deleted: "deleted",
128
+ renamed: "renamed",
129
+ copied: "added",
130
+ untracked: "untracked",
131
+ typechange: "modified",
132
+ };
133
+
134
+ /** Replace the children of `targetPath` within the tree (immutably). */
135
+ function replaceChildren(nodes: FileTreeNode[], targetPath: string, children: FileTreeNode[]): FileTreeNode[] {
136
+ return nodes.map((node) => {
137
+ if (node.path === targetPath) {
138
+ return { ...node, children };
139
+ }
140
+ if (node.kind === "dir" && node.children && targetPath.startsWith(`${node.path}/`)) {
141
+ return { ...node, children: replaceChildren(node.children, targetPath, children) };
142
+ }
143
+ return node;
144
+ });
145
+ }
146
+
147
+ /** Find a node by exact path (depth-first). */
148
+ function findNodeByPath(nodes: FileTreeNode[], path: string): FileTreeNode | undefined {
149
+ for (const node of nodes) {
150
+ if (node.path === path) return node;
151
+ if (node.kind === "dir" && node.children && path.startsWith(`${node.path}/`)) {
152
+ const hit = findNodeByPath(node.children, path);
153
+ if (hit) return hit;
154
+ }
155
+ }
156
+ return undefined;
157
+ }
158
+
159
+ // ── In-place (optimistic) tree mutations ────────────────────────────────────
160
+ // The Pierre mutation-handle analogue. These splice a single node in/out/across
161
+ // the immutable tree, PRESERVING every other node's `children` (and therefore the
162
+ // FileBrowser's expansion + selection). The old data flow re-listed the whole
163
+ // root and `setTree(rootChildren)` — that dropped every expanded dir back to the
164
+ // unexpanded marker, which is exactly the "everything refreshes / collapses to
165
+ // .config" the user saw. Mutating in place is the fix.
166
+ //
167
+ // `parent === ""` targets the root list directly. A parent that isn't present in
168
+ // the (lazily-loaded) tree means it's collapsed/unloaded — the helpers return the
169
+ // tree UNCHANGED in that case (caller treats it as "nothing visible to update",
170
+ // which is correct: a collapsed dir re-lists fresh when the user expands it).
171
+
172
+ /** True when `parent` is the root ("") or a dir node that is actually present in
173
+ * the loaded tree (so an insert/remove there would be visible). */
174
+ function parentIsLoaded(nodes: FileTreeNode[], parent: string): boolean {
175
+ if (parent === "") return true;
176
+ const node = findNodeByPath(nodes, parent);
177
+ return Boolean(node && node.kind === "dir" && node.children !== undefined);
178
+ }
179
+
180
+ /** Insert `child` under `parent` (immutably), keeping siblings sorted. A no-op
181
+ * (returns the same array) when the parent isn't a loaded dir or the child
182
+ * already exists. */
183
+ function insertNode(nodes: FileTreeNode[], parent: string, child: FileTreeNode): FileTreeNode[] {
184
+ if (parent === "") {
185
+ if (nodes.some((n) => n.path === child.path)) return nodes;
186
+ return sortNodes([...nodes, child]);
187
+ }
188
+ return nodes.map((node) => {
189
+ if (node.path === parent) {
190
+ if (node.kind !== "dir" || node.children === undefined) return node;
191
+ if (node.children.some((n) => n.path === child.path)) return node;
192
+ return { ...node, children: sortNodes([...node.children, child]) };
193
+ }
194
+ if (node.kind === "dir" && node.children && parent.startsWith(`${node.path}/`)) {
195
+ return { ...node, children: insertNode(node.children, parent, child) };
196
+ }
197
+ return node;
198
+ });
199
+ }
200
+
201
+ /** Remove the node at `path` (immutably). No-op when it isn't in the tree. */
202
+ function removeNode(nodes: FileTreeNode[], path: string): FileTreeNode[] {
203
+ const parent = parentOf(path);
204
+ if (parent === "") {
205
+ if (!nodes.some((n) => n.path === path)) return nodes;
206
+ return nodes.filter((n) => n.path !== path);
207
+ }
208
+ return nodes.map((node) => {
209
+ if (node.path === parent) {
210
+ if (node.kind !== "dir" || node.children === undefined) return node;
211
+ return { ...node, children: node.children.filter((n) => n.path !== path) };
212
+ }
213
+ if (node.kind === "dir" && node.children && parent.startsWith(`${node.path}/`)) {
214
+ return { ...node, children: removeNode(node.children, path) };
215
+ }
216
+ return node;
217
+ });
218
+ }
219
+
220
+ /** Reconcile a freshly-listed depth-1 set of children against the CURRENT nodes
221
+ * at the same level, PRESERVING expansion: an existing dir keeps its already-
222
+ * loaded `children` (so its expanded subtree survives), new entries are added,
223
+ * and entries the server no longer returns are dropped. This is the in-place
224
+ * merge a root ("") reconcile needs — a blind replace would collapse every
225
+ * expanded top-level dir back to the unexpanded marker (the reported bug). */
226
+ function mergeChildren(current: FileTreeNode[], listed: FileTreeNode[]): FileTreeNode[] {
227
+ const byPath = new Map(current.map((n) => [n.path, n] as const));
228
+ const merged = listed.map((next) => {
229
+ const existing = byPath.get(next.path);
230
+ // Keep an already-expanded dir's loaded children; otherwise take the listing's
231
+ // 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 };
234
+ }
235
+ return next;
236
+ });
237
+ return sortNodes(merged);
238
+ }
239
+
240
+ /** Root-level ("") reconcile: merge a fresh depth-1 listing into the root list
241
+ * without collapsing expanded top-level dirs. */
242
+ function mergeRootChildren(nodes: FileTreeNode[], listed: FileTreeNode[]): FileTreeNode[] {
243
+ return mergeChildren(nodes, listed);
244
+ }
245
+
246
+ /** Re-path a subtree rooted at `node` from `fromPrefix` to `toPrefix` (so a moved
247
+ * dir's descendants keep correct paths without a re-list). */
248
+ function repathNode(node: FileTreeNode, fromPrefix: string, toPrefix: string): FileTreeNode {
249
+ const newPath = toPrefix + node.path.slice(fromPrefix.length);
250
+ const next: FileTreeNode = { ...node, path: newPath, name: leafOf(newPath) };
251
+ if (node.children) next.children = node.children.map((c) => repathNode(c, fromPrefix, toPrefix));
252
+ return next;
253
+ }
254
+
255
+ /**
256
+ * Project the FileSystem service into a lazy-loaded Pierre tree. The initial
257
+ * list pulls one level (depth 1); `expand(path)` lists a directory's immediate
258
+ * children on demand (the fast lazy-tree UX). A git-status overlay tints
259
+ * modified files. Auto-refreshes when an `fs.changed` / `git.changed` event
260
+ * arrives on the live log.
261
+ */
262
+ export function useSandboxFiles(
263
+ sessionId: string | null | undefined,
264
+ options: UseSandboxFilesOptions = {},
265
+ ): UseSandboxFilesResult {
266
+ const { client, workspaceId } = useOpenGeni(options);
267
+ const enabled = (options.enabled ?? true) && Boolean(sessionId);
268
+ const rootPath = options.rootPath ?? "";
269
+
270
+ const [tree, setTree] = useState<FileTreeNode[]>([]);
271
+ // A ref mirror of the current tree — lets the optimistic path snapshot the
272
+ // pre-op tree WITHOUT relying on a `setTree` updater (which StrictMode invokes
273
+ // twice, corrupting an in-closure snapshot). Kept in sync on every set below
274
+ // and in a layout effect for any path that sets `tree` directly.
275
+ const treeRef = useRef<FileTreeNode[]>([]);
276
+ const [loading, setLoading] = useState(false);
277
+ const [expandingPaths, setExpandingPaths] = useState<Set<string>>(new Set());
278
+ const [error, setError] = useState<Error | null>(null);
279
+ const statusRef = useRef<Map<string, FileTreeStatus>>(new Map());
280
+
281
+ // ── Self-emitted fs.changed de-dupe ───────────────────────────────────────
282
+ // Every one of OUR mutations emits an `fs.changed` event back on the live log
283
+ // (source:"write"). The old flow auto-refreshed on EVERY fs.changed, so each
284
+ // edit self-triggered a full root collapse-reload (~5s, lost expansion). We now
285
+ // (a) ignore fs.changed whose `source === "write"` (our own control-plane ops),
286
+ // and (b) belt-and-braces, track the revisions WE caused so even a watch-sourced
287
+ // echo of our own write is suppressed. Only EXTERNAL changes (the agent writing,
288
+ // source:"agent"/"watch" we didn't cause) drive a targeted reconcile.
289
+ const ownRevisionsRef = useRef<Set<number>>(new Set());
290
+ const onMutationError = options.onMutationError;
291
+
292
+ // Keep the ref mirror current for the optimistic snapshot path.
293
+ treeRef.current = tree;
294
+
295
+ const applyStatus = useCallback((nodes: FileTreeNode[]): FileTreeNode[] => {
296
+ 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;
303
+ else delete next.status;
304
+ return next;
305
+ });
306
+ return walk(nodes);
307
+ }, []);
308
+
309
+ const refresh = useCallback(async () => {
310
+ if (!sessionId) return;
311
+ setLoading(true);
312
+ setError(null);
313
+ try {
314
+ // Pull the git-status overlay first (best-effort — a non-repo box just
315
+ // returns isRepo:false), then the tree, so the first paint is tinted.
316
+ try {
317
+ const status = await client.gitStatus(workspaceId, sessionId, { path: rootPath });
318
+ const overlay = new Map<string, FileTreeStatus>();
319
+ for (const file of status.files) {
320
+ const code = file.worktree ?? file.index;
321
+ const mapped = code ? GIT_STATUS_TO_TREE[code] : undefined;
322
+ if (mapped) overlay.set(file.path, mapped);
323
+ }
324
+ statusRef.current = overlay;
325
+ } catch {
326
+ statusRef.current = new Map();
327
+ }
328
+ const listed = await client.fsList(workspaceId, sessionId, { path: rootPath, depth: 1 });
329
+ const children = (listed.root.children ?? []).map(fsNodeToTree);
330
+ // 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)));
334
+ } catch (cause) {
335
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
336
+ } finally {
337
+ setLoading(false);
338
+ }
339
+ }, [client, workspaceId, sessionId, rootPath, applyStatus]);
340
+
341
+ const expand = useCallback(
342
+ async (path: string) => {
343
+ if (!sessionId) return;
344
+ // Mark this node as expanding so the FileBrowser can render a spinner while
345
+ // the (often 2-3s) Channel-A fs/list is in flight — the tree never looks
346
+ // frozen on a click.
347
+ setExpandingPaths((prev) => {
348
+ const next = new Set(prev);
349
+ next.add(path);
350
+ return next;
351
+ });
352
+ try {
353
+ const listed = await client.fsList(workspaceId, sessionId, { path, depth: 1 });
354
+ const children = (listed.root.children ?? []).map(fsNodeToTree);
355
+ setTree((prev) => applyStatus(replaceChildren(prev, path, children)));
356
+ } catch (cause) {
357
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
358
+ } 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
+ });
365
+ }
366
+ },
367
+ [client, workspaceId, sessionId, applyStatus],
368
+ );
369
+
370
+ const readFile = useCallback(
371
+ async (path: string) => {
372
+ if (!sessionId) throw new Error("no session");
373
+ return await client.fsRead(workspaceId, sessionId, { path });
374
+ },
375
+ [client, workspaceId, sessionId],
376
+ );
377
+
378
+ // TARGETED reconcile of a single directory — re-list ONE parent at depth 1 and
379
+ // splice its children in place via `replaceChildren`, preserving the rest of the
380
+ // tree's expansion. NEVER falls back to a root refresh (that's the collapse).
381
+ // Used to (a) reconcile an optimistic insert against the server's real
382
+ // size/mtime, and (b) fold an EXTERNAL (agent) change into the tree. A reconcile
383
+ // of a parent that isn't loaded (collapsed/unmounted) is a no-op — there's
384
+ // nothing visible to update, and it re-lists fresh when the user expands it.
385
+ const reconcilePath = useCallback(
386
+ async (path: string) => {
387
+ if (!sessionId) return;
388
+ // Skip parents that aren't currently loaded/expanded in the tree (nothing
389
+ // visible to update; they re-list fresh on the next expand).
390
+ if (!parentIsLoaded(treeRef.current, path)) return;
391
+ try {
392
+ const listed = await client.fsList(workspaceId, sessionId, { path, depth: 1 });
393
+ const children = (listed.root.children ?? []).map(fsNodeToTree);
394
+ if (path === "") setTree((prev) => applyStatus(mergeRootChildren(prev, children)));
395
+ else
396
+ setTree((prev) => {
397
+ const existing = findNodeByPath(prev, path);
398
+ const merged = existing?.children ? mergeChildren(existing.children, children) : children;
399
+ return applyStatus(replaceChildren(prev, path, merged));
400
+ });
401
+ } catch {
402
+ // A failed reconcile is non-fatal: the optimistic state stands. We never
403
+ // root-refresh here (that would collapse the tree the user is working in).
404
+ }
405
+ },
406
+ [client, workspaceId, sessionId, applyStatus],
407
+ );
408
+
409
+ // Run a Channel-A op behind an OPTIMISTIC tree edit. `apply` splices the change
410
+ // in immediately (preserving expansion/selection); the op runs in the
411
+ // background; on failure we revert to the pre-op snapshot and surface a toast.
412
+ // On success we keep the optimistic state and (optionally) reconcile the
413
+ // affected parent(s) to pick up the server's real size/revision — NEVER a full
414
+ // refresh. The returned promise resolves/rejects with the op so callers (the
415
+ // editor save, inline rename) can still await it.
416
+ const runOptimistic = useCallback(
417
+ async <T,>(
418
+ opName: string,
419
+ apply: (nodes: FileTreeNode[]) => FileTreeNode[],
420
+ op: () => Promise<T>,
421
+ reconcileParents: string[],
422
+ ): Promise<T> => {
423
+ // Snapshot the pre-op tree from the ref (StrictMode-safe — see treeRef).
424
+ const snapshot = treeRef.current;
425
+ setTree(applyStatus(apply(snapshot)));
426
+ try {
427
+ const res = await op();
428
+ // Remember the revision WE caused so the matching fs.changed echo is
429
+ // ignored by the event effect (no self-triggered refresh).
430
+ if (res && typeof res === "object" && "revision" in res) {
431
+ const rev = (res as { revision?: unknown }).revision;
432
+ if (typeof rev === "number") ownRevisionsRef.current.add(rev);
433
+ }
434
+ // Reconcile loaded parents to fold the server's real metadata. Sequential
435
+ // and best-effort — purely cosmetic over the already-correct optimistic UI.
436
+ for (const parent of reconcileParents) await reconcilePath(parent);
437
+ return res;
438
+ } catch (cause) {
439
+ const err = cause instanceof Error ? cause : new Error(String(cause));
440
+ // Revert the optimistic edit to the exact pre-op tree.
441
+ setTree(applyStatus(snapshot));
442
+ setError(err);
443
+ onMutationError?.(err, opName);
444
+ throw err;
445
+ }
446
+ },
447
+ [applyStatus, reconcilePath, onMutationError],
448
+ );
449
+
450
+ const writeFile = useCallback(
451
+ async (path: string, content: string): Promise<FsWriteResponse> => {
452
+ if (!sessionId) throw new Error("no session");
453
+ const parent = parentOf(path);
454
+ // Splice a new file node in immediately ONLY when the path doesn't already
455
+ // exist in a loaded parent (an editor SAVE to an existing file mutates no
456
+ // tree shape — just content — so it needs no optimistic node, no reconcile).
457
+ const exists = Boolean(findNodeByPath(tree, path));
458
+ const node: FileTreeNode = { path, name: leafOf(path), kind: "file", size: content.length };
459
+ return await runOptimistic(
460
+ "write",
461
+ (nodes) => (exists ? nodes : insertNode(nodes, parent, node)),
462
+ () => client.fsWrite(workspaceId, sessionId, { path, content, overwrite: true }),
463
+ exists ? [] : [parent],
464
+ );
465
+ },
466
+ [client, workspaceId, sessionId, tree, runOptimistic],
467
+ );
468
+
469
+ const createFile = useCallback(
470
+ async (path: string): Promise<void> => {
471
+ if (!sessionId) throw new Error("no session");
472
+ const parent = parentOf(path);
473
+ const node: FileTreeNode = { path, name: leafOf(path), kind: "file", size: 0 };
474
+ await runOptimistic(
475
+ "create file",
476
+ (nodes) => insertNode(nodes, parent, node),
477
+ () => client.fsWrite(workspaceId, sessionId, { path, content: "", overwrite: false }),
478
+ [parent],
479
+ );
480
+ },
481
+ [client, workspaceId, sessionId, runOptimistic],
482
+ );
483
+
484
+ const createDir = useCallback(
485
+ async (path: string): Promise<void> => {
486
+ if (!sessionId) throw new Error("no session");
487
+ const parent = parentOf(path);
488
+ // A freshly-created dir is empty + expanded: children:[] (not undefined, so
489
+ // it doesn't show the lazy-expand marker over a dir we KNOW is empty).
490
+ const node: FileTreeNode = { path, name: leafOf(path), kind: "dir", children: [] };
491
+ await runOptimistic(
492
+ "create folder",
493
+ (nodes) => insertNode(nodes, parent, node),
494
+ () => client.fsMkdir(workspaceId, sessionId, { path, recursive: true }),
495
+ [parent],
496
+ );
497
+ },
498
+ [client, workspaceId, sessionId, runOptimistic],
499
+ );
500
+
501
+ const deleteEntry = useCallback(
502
+ async (path: string, recursive = false): Promise<void> => {
503
+ if (!sessionId) throw new Error("no session");
504
+ await runOptimistic(
505
+ "delete",
506
+ (nodes) => removeNode(nodes, path),
507
+ () => client.fsDelete(workspaceId, sessionId, { path, recursive }),
508
+ [parentOf(path)],
509
+ );
510
+ },
511
+ [client, workspaceId, sessionId, runOptimistic],
512
+ );
513
+
514
+ const moveEntry = useCallback(
515
+ async (path: string, newPath: string, opts?: { overwrite?: boolean }): Promise<void> => {
516
+ if (!sessionId) throw new Error("no session");
517
+ const from = parentOf(path);
518
+ const to = parentOf(newPath);
519
+ await runOptimistic(
520
+ "move",
521
+ (nodes) => {
522
+ const moving = findNodeByPath(nodes, path);
523
+ if (!moving) return nodes; // not loaded — let the reconcile pick it up
524
+ // Re-path the moved subtree, drop it from its old parent, splice into new.
525
+ const moved = repathNode(moving, path, newPath);
526
+ return insertNode(removeNode(nodes, path), to, moved);
527
+ },
528
+ () =>
529
+ client.fsMove(workspaceId, sessionId, {
530
+ path,
531
+ newPath,
532
+ overwrite: opts?.overwrite ?? false,
533
+ }),
534
+ to === from ? [from] : [from, to],
535
+ );
536
+ },
537
+ [client, workspaceId, sessionId, runOptimistic],
538
+ );
539
+
540
+ // Initial load + reset on identity change.
541
+ useEffect(() => {
542
+ if (!enabled) {
543
+ setTree([]);
544
+ return;
545
+ }
546
+ void refresh();
547
+ }, [enabled, refresh]);
548
+
549
+ // Re-pull JUST the git-status overlay and re-tint the existing tree in place —
550
+ // no fs re-list, no collapse. This is all a `git.changed` (commit/stage/checkout)
551
+ // needs: the tree SHAPE is unchanged, only the tints move.
552
+ const refreshGitOverlay = useCallback(async () => {
553
+ if (!sessionId) return;
554
+ try {
555
+ const status = await client.gitStatus(workspaceId, sessionId, { path: rootPath });
556
+ const overlay = new Map<string, FileTreeStatus>();
557
+ for (const file of status.files) {
558
+ const code = file.worktree ?? file.index;
559
+ const mapped = code ? GIT_STATUS_TO_TREE[code] : undefined;
560
+ if (mapped) overlay.set(file.path, mapped);
561
+ }
562
+ statusRef.current = overlay;
563
+ setTree((prev) => applyStatus(prev));
564
+ } catch {
565
+ /* a non-repo box has no overlay — leave the tree untinted */
566
+ }
567
+ }, [client, workspaceId, sessionId, rootPath, applyStatus]);
568
+
569
+ // Auto-reconcile on fs/git change notifications — TARGETED, never a root
570
+ // collapse-reload, and de-duped against our OWN mutations.
571
+ //
572
+ // • Our own ops (`source:"write"`, or a revision WE caused) are IGNORED —
573
+ // the optimistic edit already reflects them, so a refresh here would be the
574
+ // pointless 5s collapse the user reported.
575
+ // • An EXTERNAL fs.changed (the agent writing files: `source:"agent"`/`watch`)
576
+ // reconciles ONLY the affected parent directories (in place, expansion
577
+ // preserved). Bursts are debounced into a single reconcile pass.
578
+ // • A git.changed just re-tints (refreshes the status overlay) — no fs re-list.
579
+ 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
+
585
+ useEffect(() => {
586
+ if (!enabled || !events) return;
587
+ let sawNew = false;
588
+ for (const event of events) {
589
+ if (event.sequence <= lastSeqRef.current) continue;
590
+ if (event.type === "fs.changed") {
591
+ sawNew = true;
592
+ const payload = event.payload as FsChangedPayload | null;
593
+ if (!payload || typeof payload !== "object") continue;
594
+ // Suppress our own writes: the optimistic tree already shows them.
595
+ if (payload.source === "write") continue;
596
+ if (typeof payload.revision === "number" && ownRevisionsRef.current.has(payload.revision)) continue;
597
+ for (const change of payload.changes ?? []) {
598
+ pendingParentsRef.current.add(parentOf(change.path));
599
+ if (change.oldPath) pendingParentsRef.current.add(parentOf(change.oldPath));
600
+ }
601
+ } else if (event.type === "git.changed") {
602
+ sawNew = true;
603
+ const payload = event.payload as GitChangedPayload | null;
604
+ // A git.changed our own write triggered (commit/stage from the agent is
605
+ // external; a checkout we caused isn't — but we don't cause git ops here,
606
+ // 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;
609
+ pendingGitRef.current = true;
610
+ }
611
+ }
612
+ // 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;
614
+ if (!sawNew) return;
615
+
616
+ if (debounceRef.current) clearTimeout(debounceRef.current);
617
+ debounceRef.current = setTimeout(() => {
618
+ debounceRef.current = null;
619
+ const parents = pendingParentsRef.current;
620
+ pendingParentsRef.current = new Set();
621
+ const wantGit = pendingGitRef.current;
622
+ pendingGitRef.current = false;
623
+ // Reconcile the changed directories (in place). A git change re-tints first
624
+ // so the freshly-listed nodes get the correct overlay.
625
+ void (async () => {
626
+ if (wantGit) await refreshGitOverlay();
627
+ for (const parent of parents) await reconcilePath(parent);
628
+ })();
629
+ }, 150);
630
+ }, [enabled, events, reconcilePath, refreshGitOverlay]);
631
+
632
+ useEffect(
633
+ () => () => {
634
+ if (debounceRef.current) clearTimeout(debounceRef.current);
635
+ },
636
+ [],
637
+ );
638
+
639
+ // Re-list when the box first becomes warm. The FileSystem capability is
640
+ // advertised on a cold box too, so the mount-time `refresh()` can run before
641
+ // the box is up (empty/errored result); without an `fs.changed` event the tree
642
+ // would stay empty forever. A cold->warm transition re-lists once the box is
643
+ // actually serving — the real fix for the "No files" the deployed app showed.
644
+ const wasLiveRef = useRef(false);
645
+ const liveness = options.liveness;
646
+ useEffect(() => {
647
+ const live = liveness === "warm" || liveness === "draining";
648
+ if (enabled && live && !wasLiveRef.current) {
649
+ wasLiveRef.current = true;
650
+ void refresh();
651
+ } else if (!live) {
652
+ wasLiveRef.current = false;
653
+ }
654
+ }, [enabled, liveness, refresh]);
655
+
656
+ return {
657
+ tree,
658
+ expand,
659
+ expandingPaths,
660
+ readFile,
661
+ writeFile,
662
+ createFile,
663
+ createDir,
664
+ deleteEntry,
665
+ moveEntry,
666
+ refresh,
667
+ loading,
668
+ error,
669
+ };
670
+ }