- {status === "preparing" &&
}
- {status === "failed" &&
}
+ {status === "preparing" && (
+
+ )}
+ {status === "failed" &&
}
{status === "preparing" && "Preparing environment..."}
{status === "failed" && (prepareState.errorMessage ?? "Environment preparation failed")}
diff --git a/apps/web/components/task/file-browser-hooks.ts b/apps/web/components/task/file-browser-hooks.ts
index 6ca5300780..f8d1e81ed7 100644
--- a/apps/web/components/task/file-browser-hooks.ts
+++ b/apps/web/components/task/file-browser-hooks.ts
@@ -277,6 +277,36 @@ function useTreeLoader(ctx: TreeLoaderContext) {
return loadTree;
}
+function useFileChangeSubscription({
+ sessionIdRef,
+ expandedPaths,
+ setTree,
+ setLoadState,
+}: {
+ sessionIdRef: React.MutableRefObject;
+ expandedPaths: Set;
+ setTree: React.Dispatch>;
+ setLoadState: React.Dispatch>;
+}) {
+ useEffect(() => {
+ const client = getWebSocketClient();
+ if (!client) return;
+ return client.on("session.workspace.file.changes", (msg) => {
+ const changes = msg.payload?.changes;
+ if (!changes || changes.length === 0) return;
+ applyFileChanges({
+ client,
+ sessionId: sessionIdRef.current,
+ expandedPaths,
+ changes,
+ setTree,
+ setLoadState,
+ });
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [expandedPaths]);
+}
+
/**
* Hook for tree loading with retry logic, file-change subscription, and expanded state.
* @param sessionId - Session ID for API calls (agentctl routing).
@@ -317,11 +347,18 @@ export function useFileBrowserTree(sessionId: string, resetKey?: string) {
setLoadError,
});
- // Full reset: clear tree + loading state. Only fires when the environment changes.
+ // Refs for effects below to read without adding deps — avoids infinite
+ // retry loops and wipe-on-reconnect when `isReady`/`loadState`/`tree` tick.
+ const agentctlIsReadyRef = useRef(agentctlStatus.isReady);
+ const loadStateRef = useRef(loadState);
+ const treeRef = useRef(tree);
+ agentctlIsReadyRef.current = agentctlStatus.isReady;
+ loadStateRef.current = loadState;
+ treeRef.current = tree;
useEffect(() => {
setTree(null);
setIsLoadingTree(true);
- setLoadState("loading");
+ setLoadState(agentctlIsReadyRef.current ? "loading" : "waiting");
setLoadError(null);
retryAttemptRef.current = 0;
clearRetryTimer();
@@ -329,16 +366,26 @@ export function useFileBrowserTree(sessionId: string, resetKey?: string) {
const savedPaths = getFilesPanelExpandedPaths(effectiveResetKey);
setExpandedPaths(savedPaths.length > 0 ? new Set(savedPaths) : new Set());
if (savedPaths.length > 0) hasInitializedExpandedRef.current = effectiveResetKey;
- void loadTree({ resetRetry: true });
+ // Only load now if agentctl is ready; otherwise the sibling effect fires
+ // on the ready flip. Prevents burning the retry budget during slow prepare.
+ if (agentctlIsReadyRef.current) void loadTree({ resetRetry: true });
+ else setIsLoadingTree(false);
return () => {
clearRetryTimer();
};
}, [clearRetryTimer, loadTree, effectiveResetKey]);
+ // Fire the initial load on the waiting → ready transition. `loadState` is
+ // read via a ref so a failed load (which sets state back to "waiting") does
+ // not re-fire this effect and cancel the retry timer via `resetRetry: true`.
+ // "loaded" only counts when a tree is actually present — `applyFileChanges`
+ // can flip state to "loaded" while the tree is still null.
useEffect(() => {
- if (!agentctlStatus.isReady || loadState === "loaded") return;
+ if (!agentctlStatus.isReady) return;
+ if (loadStateRef.current === "loading") return;
+ if (loadStateRef.current === "loaded" && treeRef.current) return;
void loadTree({ resetRetry: true });
- }, [agentctlStatus.isReady, loadState, loadTree]);
+ }, [agentctlStatus.isReady, loadTree]);
useEffect(() => {
if (!tree || isLoadingTree || hasInitializedExpandedRef.current === effectiveResetKey) return;
@@ -353,22 +400,7 @@ export function useFileBrowserTree(sessionId: string, resetKey?: string) {
}
}, [expandedPaths, effectiveResetKey]);
- useEffect(() => {
- const client = getWebSocketClient();
- if (!client) return;
- return client.on("session.workspace.file.changes", (msg) => {
- const changes = msg.payload?.changes;
- if (!changes || changes.length === 0) return;
- applyFileChanges({
- client,
- sessionId: sessionIdRef.current,
- expandedPaths,
- changes,
- setTree,
- setLoadState,
- });
- });
- }, [expandedPaths]);
+ useFileChangeSubscription({ sessionIdRef, expandedPaths, setTree, setLoadState });
const collapseAll = useCallback(() => {
setExpandedPaths(new Set());
diff --git a/apps/web/components/task/file-browser-load-state.tsx b/apps/web/components/task/file-browser-load-state.tsx
index 8a720402ab..7d64be0bf6 100644
--- a/apps/web/components/task/file-browser-load-state.tsx
+++ b/apps/web/components/task/file-browser-load-state.tsx
@@ -35,7 +35,10 @@ export function renderSessionOrLoadState({
}
if (loadState === "waiting") {
return (
-
+
Preparing workspace...
@@ -43,7 +46,7 @@ export function renderSessionOrLoadState({
}
if (loadState === "manual") {
return (
-
+
{loadError ?? "Workspace is still starting."}