@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.
- package/README.md +1 -1
- package/dist/{chunk-5C7RGAWA.js → chunk-NFYVQWIB.js} +170 -64
- package/dist/chunk-NFYVQWIB.js.map +1 -0
- package/dist/index.d.ts +791 -145
- package/dist/index.js +5447 -2213
- package/dist/index.js.map +1 -1
- package/dist/{machines-BeZ3bD3t.d.ts → machines-CnlMb7E-.d.ts} +5 -5
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +1 -1
- package/package.json +17 -12
- package/src/approvals.ts +16 -4
- package/src/client.ts +32 -5
- package/src/commands/index.ts +1 -7
- package/src/commands/registry.ts +46 -12
- package/src/components/chat-composer.tsx +135 -39
- package/src/components/code-editor.tsx +28 -36
- package/src/components/command-palette.tsx +26 -5
- package/src/components/desktop-viewer.tsx +29 -16
- package/src/components/diff-view.tsx +27 -189
- package/src/components/enrollment-consent.tsx +28 -10
- package/src/components/enrollment-device-flow.tsx +8 -5
- package/src/components/file-browser.tsx +190 -56
- package/src/components/fleet-tile.tsx +13 -4
- package/src/components/machine-card.tsx +16 -4
- package/src/components/machine-dock-bar.tsx +20 -8
- package/src/components/machine-metrics.tsx +31 -8
- package/src/components/machine-status-pill.tsx +26 -5
- package/src/components/machines-dashboard.tsx +8 -2
- package/src/components/markdown.tsx +39 -9
- package/src/components/message-timeline.tsx +633 -109
- package/src/components/pierre-diff.tsx +83 -15
- package/src/components/pierre-file.tsx +10 -9
- package/src/components/sandbox-files.tsx +110 -227
- package/src/components/sandbox-terminal.tsx +256 -88
- package/src/components/sandbox-workspace.tsx +823 -0
- package/src/components/session-status.tsx +28 -2
- package/src/components/workbench-changes.tsx +541 -0
- package/src/components/workspace-dock.tsx +85 -22
- package/src/hooks/internal.ts +5 -2
- package/src/hooks/use-available-models.ts +7 -2
- package/src/hooks/use-billing-usage.ts +4 -1
- package/src/hooks/use-codex-accounts.ts +74 -44
- package/src/hooks/use-composer.ts +57 -36
- package/src/hooks/use-environments.ts +92 -47
- package/src/hooks/use-file-attachments.ts +101 -55
- package/src/hooks/use-goal.ts +52 -16
- package/src/hooks/use-machine-chip.ts +134 -0
- package/src/hooks/use-machines.ts +34 -16
- package/src/hooks/use-packs.ts +24 -19
- package/src/hooks/use-relay-frame-stream.ts +5 -2
- package/src/hooks/use-rigs.ts +335 -0
- package/src/hooks/use-sandbox-files.ts +213 -39
- package/src/hooks/use-sandbox-git.ts +188 -11
- package/src/hooks/use-sandbox-terminal.ts +18 -14
- package/src/hooks/use-scheduled-tasks.ts +10 -2
- package/src/hooks/use-session-capabilities.ts +77 -20
- package/src/hooks/use-session-control.ts +49 -21
- package/src/hooks/use-session-events.ts +48 -20
- package/src/hooks/use-session-lineage.ts +119 -0
- package/src/hooks/use-session.ts +43 -28
- package/src/hooks/use-slash-commands.ts +6 -4
- package/src/hooks/use-turn-queue.ts +74 -147
- package/src/hooks/use-windowed-sections.ts +204 -0
- package/src/hooks/use-workspace-capture.ts +233 -0
- package/src/hooks/use-workspace-edit.ts +231 -0
- package/src/hooks/use-workspace-sessions.ts +48 -5
- package/src/hooks/use-workspaces.ts +18 -15
- package/src/index.ts +128 -23
- package/src/lib/format.ts +44 -6
- package/src/lib/xterm-renderer.ts +65 -0
- package/src/lib/xterm-theme.ts +224 -18
- package/src/machines.ts +13 -3
- package/src/provider.tsx +3 -1
- package/src/timeline/activity-rail.tsx +213 -54
- package/src/timeline/disclosure-context.tsx +12 -2
- package/src/timeline/index.ts +21 -2
- package/src/timeline/parsers.ts +44 -10
- package/src/timeline/projection.ts +435 -84
- package/src/timeline/shared.tsx +111 -24
- package/src/timeline/tool-diff.tsx +24 -20
- package/src/timeline/tool-renderers.tsx +98 -21
- package/src/timeline/turn-summary.tsx +76 -23
- package/src/timeline/types.ts +96 -12
- package/styles/tokens.css +2 -2
- 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(
|
|
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
|
|
233
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
list.map((node) => {
|
|
300
|
-
const
|
|
301
|
-
const
|
|
302
|
-
|
|
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
|
|
332
|
-
//
|
|
333
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
547
|
-
|
|
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))
|
|
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 (
|
|
608
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
};
|