feat: improve workspace file tree loading
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Set<string>>(new Set());
|
||||
const [loadingPaths, setLoadingPaths] = useState<Set<string>>(new Set());
|
||||
const [isLoadingTree, setIsLoadingTree] = useState(true);
|
||||
const [loadState, setLoadState] = useState<'loading' | 'waiting' | 'loaded' | 'manual' | 'error'>('loading');
|
||||
const [loadError, setLoadError] = useState<string | null>(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<NodeJS.Timeout | null>(null);
|
||||
const hasRestoredScrollRef = useRef<string | null>(null);
|
||||
const hasInitializedExpandedRef = useRef<string | null>(null);
|
||||
const retryAttemptRef = useRef(0);
|
||||
const retryTimerRef = useRef<NodeJS.Timeout | null>(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
|
||||
<ScrollArea className="flex-1" ref={scrollAreaRef}>
|
||||
{isSearchOpen && searchResults !== null ? (
|
||||
renderSearchResults()
|
||||
) : isLoadingTree ? (
|
||||
) : loadState === 'loading' || isLoadingTree ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">Loading files...</div>
|
||||
) : loadState === 'waiting' ? (
|
||||
<div className="p-4 text-sm text-muted-foreground flex items-center gap-2">
|
||||
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Preparing workspace...
|
||||
</div>
|
||||
) : loadState === 'manual' ? (
|
||||
<div className="p-4 text-sm text-muted-foreground space-y-2">
|
||||
<div>{loadError ?? 'Workspace is still starting.'}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-foreground underline cursor-pointer"
|
||||
onClick={() => void loadTree({ resetRetry: true })}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : tree ? (
|
||||
<div className="pb-2">{renderTreeNode(tree)}</div>
|
||||
) : (
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -109,6 +109,37 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
|
||||
agentExecutionId: payload.agent_execution_id,
|
||||
updatedAt: message.timestamp,
|
||||
});
|
||||
|
||||
const existingSession = store.getState().taskSessions.items[payload.session_id];
|
||||
if (!existingSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionUpdate: Record<string, unknown> = {};
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user