diff --git a/apps/backend/internal/agent/lifecycle/event_types.go b/apps/backend/internal/agent/lifecycle/event_types.go index d8de52b626..609fd616c2 100644 --- a/apps/backend/internal/agent/lifecycle/event_types.go +++ b/apps/backend/internal/agent/lifecycle/event_types.go @@ -27,6 +27,9 @@ type AgentctlEventPayload struct { SessionID string `json:"session_id"` AgentExecutionID string `json:"agent_execution_id"` ErrorMessage string `json:"error_message,omitempty"` + WorktreeID string `json:"worktree_id,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + WorktreeBranch string `json:"worktree_branch,omitempty"` } // ACPSessionCreatedPayload is the payload when an ACP session is created. diff --git a/apps/backend/internal/agent/lifecycle/events.go b/apps/backend/internal/agent/lifecycle/events.go index 2b8447a7c6..029d35ae1d 100644 --- a/apps/backend/internal/agent/lifecycle/events.go +++ b/apps/backend/internal/agent/lifecycle/events.go @@ -68,11 +68,25 @@ func (p *EventPublisher) PublishAgentctlEvent(ctx context.Context, eventType str return } + var worktreeID string + var worktreeBranch string + if execution.Metadata != nil { + if id, ok := execution.Metadata[MetadataKeyWorktreeID].(string); ok { + worktreeID = id + } + if branch, ok := execution.Metadata[MetadataKeyWorktreeBranch].(string); ok { + worktreeBranch = branch + } + } + payload := AgentctlEventPayload{ TaskID: execution.TaskID, SessionID: execution.SessionID, AgentExecutionID: execution.ID, ErrorMessage: errMsg, + WorktreeID: worktreeID, + WorktreePath: execution.WorkspacePath, + WorktreeBranch: worktreeBranch, } event := bus.NewEvent(eventType, "agent-manager", payload) @@ -505,4 +519,3 @@ func (p *EventPublisher) PublishAvailableCommands(execution *AgentExecution, com zap.Error(err)) } } - diff --git a/apps/web/components/task/file-browser.tsx b/apps/web/components/task/file-browser.tsx index 82c9684057..9cb3f6a0f9 100644 --- a/apps/web/components/task/file-browser.tsx +++ b/apps/web/components/task/file-browser.tsx @@ -8,6 +8,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { getWebSocketClient } from '@/lib/ws/connection'; import { requestFileTree, requestFileContent, searchWorkspaceFiles } from '@/lib/ws/workspace-files'; import type { FileTreeNode, FileContentResponse, OpenFileTab } from '@/lib/types/backend'; +import { useSessionAgentctl } from '@/hooks/domains/session/use-session-agentctl'; import { getFilesPanelExpandedPaths, setFilesPanelExpandedPaths, @@ -29,6 +30,9 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen const [expandedPaths, setExpandedPaths] = useState>(new Set()); const [loadingPaths, setLoadingPaths] = useState>(new Set()); const [isLoadingTree, setIsLoadingTree] = useState(true); + const [loadState, setLoadState] = useState<'loading' | 'waiting' | 'loaded' | 'manual' | 'error'>('loading'); + const [loadError, setLoadError] = useState(null); + const agentctlStatus = useSessionAgentctl(sessionId); // Search state const [localSearchQuery, setLocalSearchQuery] = useState(''); @@ -42,6 +46,12 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen const scrollSaveTimeoutRef = useRef(null); const hasRestoredScrollRef = useRef(null); const hasInitializedExpandedRef = useRef(null); + const retryAttemptRef = useRef(0); + const retryTimerRef = useRef(null); + const loadInFlightRef = useRef(false); + + const MAX_RETRY_ATTEMPTS = 4; + const RETRY_DELAYS_MS = [1000, 2000, 5000, 10000]; // Focus search input when search opens useEffect(() => { @@ -62,11 +72,66 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen } }, [isSearchOpen]); + const clearRetryTimer = useCallback(() => { + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } + }, []); + + const loadTree = useCallback(async (options?: { resetRetry?: boolean }) => { + if (loadInFlightRef.current) return; + loadInFlightRef.current = true; + setIsLoadingTree(true); + setLoadState('loading'); + setLoadError(null); + + if (options?.resetRetry) { + retryAttemptRef.current = 0; + clearRetryTimer(); + } + + try { + const client = getWebSocketClient(); + if (!client) { + throw new Error('WebSocket client not available'); + } + + const response = await requestFileTree(client, sessionId, '', 1); + setTree(response.root ?? null); + setLoadState('loaded'); + retryAttemptRef.current = 0; + clearRetryTimer(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to load file tree'; + setLoadError(message); + + if (retryAttemptRef.current < MAX_RETRY_ATTEMPTS) { + const delay = RETRY_DELAYS_MS[Math.min(retryAttemptRef.current, RETRY_DELAYS_MS.length - 1)]; + retryAttemptRef.current += 1; + setLoadState('waiting'); + clearRetryTimer(); + retryTimerRef.current = setTimeout(() => { + void loadTree(); + }, delay); + } else { + setLoadState('manual'); + } + } finally { + setIsLoadingTree(false); + loadInFlightRef.current = false; + } + }, [clearRetryTimer, sessionId]); + // Load initial tree - always try to load, don't wait for agentctl ready status useEffect(() => { setTree(null); setLoadingPaths(new Set()); setIsLoadingTree(true); + setLoadState('loading'); + setLoadError(null); + retryAttemptRef.current = 0; + clearRetryTimer(); // Reset the auto-expand ref so it will run for the new session hasInitializedExpandedRef.current = null; @@ -81,22 +146,15 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen // Leave hasInitializedExpandedRef.current as null so auto-expand will run } - const loadTree = async () => { - try { - const client = getWebSocketClient(); - if (!client) return; + void loadTree({ resetRetry: true }); + }, [clearRetryTimer, loadTree, sessionId]); - const response = await requestFileTree(client, sessionId, '', 1); - setTree(response.root); - } catch (error) { - console.error('Failed to load file tree:', error); - } finally { - setIsLoadingTree(false); - } - }; - - loadTree(); - }, [sessionId]); + // When agentctl becomes ready, retry loading the tree + useEffect(() => { + if (!agentctlStatus.isReady) return; + if (loadState === 'loaded') return; + void loadTree({ resetRetry: true }); + }, [agentctlStatus.isReady, loadState, loadTree]); // Auto-expand first level folders and load their children // Note: We intentionally use tree?.children?.length instead of tree to avoid re-running when tree updates @@ -152,7 +210,8 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen // Refresh the root tree when any file changes try { const response = await requestFileTree(client, sessionId, '', 1); - setTree(response.root); + setTree(response.root ?? null); + setLoadState('loaded'); } catch (error) { console.error('Failed to refresh file tree:', error); } @@ -244,6 +303,12 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen }; }, []); + useEffect(() => { + return () => { + clearRetryTimer(); + }; + }, [clearRetryTimer]); + const toggleExpand = useCallback(async (node: FileTreeNode) => { if (!node.is_dir) return; @@ -482,8 +547,24 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen {isSearchOpen && searchResults !== null ? ( renderSearchResults() - ) : isLoadingTree ? ( + ) : loadState === 'loading' || isLoadingTree ? (
Loading files...
+ ) : loadState === 'waiting' ? ( +
+ + Preparing workspace... +
+ ) : loadState === 'manual' ? ( +
+
{loadError ?? 'Workspace is still starting.'}
+ +
) : tree ? (
{renderTreeNode(tree)}
) : ( diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index c79ceb24af..8d7486caf9 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -190,6 +190,9 @@ export type TaskSessionAgentctlPayload = { session_id: string; agent_execution_id?: string; error_message?: string; + worktree_id?: string; + worktree_path?: string; + worktree_branch?: string; }; export type FileInfo = { diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index 404fa50b5d..f8438cc5be 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -109,6 +109,37 @@ export function registerTaskSessionHandlers(store: StoreApi): WsHandle agentExecutionId: payload.agent_execution_id, updatedAt: message.timestamp, }); + + const existingSession = store.getState().taskSessions.items[payload.session_id]; + if (!existingSession) { + return; + } + + const sessionUpdate: Record = {}; + if (payload.worktree_id) sessionUpdate.worktree_id = payload.worktree_id; + if (payload.worktree_path) sessionUpdate.worktree_path = payload.worktree_path; + if (payload.worktree_branch) sessionUpdate.worktree_branch = payload.worktree_branch; + + if (Object.keys(sessionUpdate).length > 0) { + store.getState().setTaskSession({ + ...existingSession, + ...sessionUpdate, + }); + } + + if (payload.worktree_id) { + store.getState().setWorktree({ + id: payload.worktree_id, + sessionId: payload.session_id, + repositoryId: existingSession.repository_id ?? undefined, + path: payload.worktree_path ?? existingSession.worktree_path ?? undefined, + branch: payload.worktree_branch ?? existingSession.worktree_branch ?? undefined, + }); + const existing = store.getState().sessionWorktreesBySessionId.itemsBySessionId[payload.session_id] ?? []; + if (!existing.includes(payload.worktree_id)) { + store.getState().setSessionWorktrees(payload.session_id, [...existing, payload.worktree_id]); + } + } }, 'session.agentctl_error': (message) => { const payload = message.payload;