@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.
- package/README.md +68 -12
- package/dist/{chunk-U3CSCMWA.js → chunk-FK6VG4LL.js} +9 -13
- package/dist/chunk-FK6VG4LL.js.map +1 -0
- package/dist/{chunk-HFO4ERGQ.js → chunk-FWXGSNXT.js} +2 -2
- package/dist/{chunk-SRFUT2ZU.js → chunk-I4MPFDRP.js} +119 -63
- package/dist/chunk-I4MPFDRP.js.map +1 -0
- package/dist/{chunk-EB67J347.js → chunk-KZ73RL76.js} +111 -31
- package/dist/chunk-KZ73RL76.js.map +1 -0
- package/dist/{chunk-LWR4MXSS.js → chunk-LHLYWG5R.js} +10 -1
- package/dist/chunk-LHLYWG5R.js.map +1 -0
- package/dist/{chunk-L3EWO3UK.js → chunk-NJ7M2B6Z.js} +445 -84
- package/dist/chunk-NJ7M2B6Z.js.map +1 -0
- package/dist/{chunk-VCBFUSUZ.js → chunk-O4VXOBGF.js} +3 -3
- package/dist/{chunk-VCBFUSUZ.js.map → chunk-O4VXOBGF.js.map} +1 -1
- package/dist/{chunk-6EDV6YFE.js → chunk-OL6QVXGF.js} +121 -29
- package/dist/chunk-OL6QVXGF.js.map +1 -0
- package/dist/chunk-VZTQ73XT.js +86 -0
- package/dist/chunk-VZTQ73XT.js.map +1 -0
- package/dist/{chunk-EHSYZ4KP.js → chunk-WYA65Y3F.js} +16 -16
- package/dist/chunk-WYA65Y3F.js.map +1 -0
- package/dist/{chunk-FD72GJMK.js → chunk-XCNTXWBB.js} +274 -125
- package/dist/chunk-XCNTXWBB.js.map +1 -0
- package/dist/client.d.ts +1 -1
- package/dist/components/chat-composer.d.ts +7 -2
- package/dist/components/composer-transcription-control.d.ts +2 -0
- package/dist/components/composer.d.ts +14 -0
- package/dist/components/machines/machine-detail.d.ts +3 -1
- package/dist/components/machines-dashboard.d.ts +3 -3
- package/dist/components/message-timeline.d.ts +12 -2
- package/dist/components/sandbox-files.d.ts +2 -2
- package/dist/components/sandbox-terminal.d.ts +29 -0
- package/dist/components/sandbox-workspace.d.ts +5 -1
- package/dist/components/workspace-dock.d.ts +3 -1
- package/dist/composer.d.ts +2 -2
- package/dist/composer.js +6 -6
- package/dist/hooks/internal.d.ts +6 -0
- package/dist/hooks/use-codex-accounts.d.ts +1 -1
- package/dist/hooks/use-environments.d.ts +10 -4
- package/dist/hooks/use-machines.d.ts +7 -0
- package/dist/hooks/use-sandbox-files.d.ts +19 -0
- package/dist/hooks/use-sandbox-git.d.ts +7 -0
- package/dist/hooks/use-transcription.d.ts +2 -2
- package/dist/hooks/use-voice-input.d.ts +13 -4
- package/dist/index.d.ts +6 -5
- package/dist/index.js +904 -538
- package/dist/index.js.map +1 -1
- package/dist/lib/composer-responsive-context.d.ts +9 -0
- package/dist/lib/use-portal-token-style.d.ts +14 -3
- package/dist/machines.js +2 -2
- package/dist/{queue-surface-implementation-J4XJFKRW.js → queue-surface-implementation-U5JFG7HT.js} +19 -18
- package/dist/queue-surface-implementation-U5JFG7HT.js.map +1 -0
- package/dist/realtime/realtime-control.d.ts +9 -0
- package/dist/realtime.js +29 -32
- package/dist/realtime.js.map +1 -1
- package/dist/session-ui.js +5 -5
- package/dist/session.js +4 -4
- package/dist/timeline/activity-rail.d.ts +3 -2
- package/dist/timeline/index.d.ts +2 -2
- package/dist/timeline/parsers.d.ts +3 -3
- package/dist/timeline/registry.d.ts +3 -0
- package/dist/timeline/screenshot-lightbox.d.ts +1 -1
- package/dist/timeline/types.d.ts +4 -0
- package/dist/voice-recording-store.d.ts +8 -2
- package/package.json +20 -4
- package/src/client.ts +3 -0
- package/src/components/chat-composer.tsx +12 -2
- package/src/components/code-editor.tsx +1 -1
- package/src/components/command-palette.tsx +1 -1
- package/src/components/composer-transcription-control.tsx +41 -16
- package/src/components/composer.tsx +52 -26
- package/src/components/copy-button.tsx +2 -2
- package/src/components/desktop-viewer.tsx +2 -2
- package/src/components/file-browser.tsx +39 -17
- package/src/components/human-input-form.tsx +2 -2
- package/src/components/machine-card.tsx +1 -1
- package/src/components/machines/machine-detail.tsx +39 -15
- package/src/components/machines-dashboard.tsx +7 -6
- package/src/components/message-timeline.tsx +44 -11
- package/src/components/model-picker.tsx +2 -2
- package/src/components/model-policy-picker.tsx +16 -15
- package/src/components/queue-surface-implementation.tsx +20 -16
- package/src/components/queue-surface-state.tsx +2 -2
- package/src/components/sandbox-files.tsx +140 -60
- package/src/components/sandbox-terminal.tsx +24 -5
- package/src/components/sandbox-workspace.tsx +180 -142
- package/src/components/session-chrome.tsx +14 -14
- package/src/components/tooltip.tsx +13 -16
- package/src/components/user-message-body.tsx +1 -1
- package/src/components/workbench-changes.tsx +4 -4
- package/src/components/workspace-dock.tsx +13 -5
- package/src/composer.ts +3 -0
- package/src/hooks/internal.ts +132 -38
- package/src/hooks/use-codex-accounts.ts +4 -4
- package/src/hooks/use-environments.ts +24 -5
- package/src/hooks/use-file-attachments.ts +70 -32
- package/src/hooks/use-goal.ts +17 -5
- package/src/hooks/use-machines.ts +47 -1
- package/src/hooks/use-sandbox-files.ts +310 -59
- package/src/hooks/use-sandbox-git.ts +148 -41
- package/src/hooks/use-session-capabilities.ts +35 -24
- package/src/hooks/use-session-events.ts +18 -4
- package/src/hooks/use-transcription.ts +4 -14
- package/src/hooks/use-turn-queue.ts +17 -5
- package/src/hooks/use-voice-input.ts +481 -36
- package/src/hooks/use-windowed-sections.ts +9 -7
- package/src/index.ts +11 -6
- package/src/lib/composer-responsive-context.ts +15 -0
- package/src/lib/use-portal-token-style.ts +60 -15
- package/src/provider.tsx +52 -7
- package/src/realtime/dropdown-menu.tsx +15 -19
- package/src/realtime/realtime-control.tsx +18 -7
- package/src/timeline/activity-rail.tsx +15 -6
- package/src/timeline/index.ts +1 -1
- package/src/timeline/parsers.ts +33 -20
- package/src/timeline/projection.ts +23 -0
- package/src/timeline/registry.ts +7 -0
- package/src/timeline/screenshot-lightbox.tsx +15 -4
- package/src/timeline/shared.tsx +8 -8
- package/src/timeline/tool-renderers.tsx +166 -22
- package/src/timeline/turn-summary.tsx +6 -6
- package/src/timeline/types.ts +4 -0
- package/src/voice-recording-store.ts +15 -0
- package/styles/compiled.css +5065 -0
- package/styles/compiled.d.ts +2 -0
- package/styles/effective-tokens.css +27 -0
- package/styles/index.css +3 -0
- package/styles/responsive.css +196 -0
- package/styles/responsive.d.ts +2 -0
- package/styles/tokens.css +50 -17
- package/dist/chunk-6EDV6YFE.js.map +0 -1
- package/dist/chunk-EB67J347.js.map +0 -1
- package/dist/chunk-EHSYZ4KP.js.map +0 -1
- package/dist/chunk-FD72GJMK.js.map +0 -1
- package/dist/chunk-JB5725BI.js +0 -48
- package/dist/chunk-JB5725BI.js.map +0 -1
- package/dist/chunk-L3EWO3UK.js.map +0 -1
- package/dist/chunk-LWR4MXSS.js.map +0 -1
- package/dist/chunk-SRFUT2ZU.js.map +0 -1
- package/dist/chunk-U3CSCMWA.js.map +0 -1
- package/dist/queue-surface-implementation-J4XJFKRW.js.map +0 -1
- /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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
//
|
|
476
|
-
//
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
{
|
|
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
|
-
//
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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 = {
|
|
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 = {
|
|
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
|
-
() =>
|
|
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 = {
|
|
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
|
|
1157
|
+
const batch = await client.gitReadBatch(
|
|
974
1158
|
workspaceId,
|
|
975
1159
|
sessionId,
|
|
976
|
-
{ path:
|
|
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
|
|
982
|
-
const
|
|
983
|
-
|
|
984
|
-
|
|
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,
|
|
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
|
-
|
|
1053
|
-
|
|
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
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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
|
-
},
|
|
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
|
};
|