From d1d08bc49055f2100a182e67f79d945c43b4320a Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Thu, 20 Nov 2025 21:21:46 +0700 Subject: [PATCH] feat(frontend): integration of events from execution and planning agents within a single conversation (#11786) --- .../features/chat/change-agent-button.tsx | 29 +- .../features/chat/interactive-chat-box.tsx | 13 +- .../features/controls/agent-status.tsx | 19 +- .../conversation-websocket-context.tsx | 417 +++++++++++++++--- .../contexts/websocket-provider-wrapper.tsx | 12 + .../use-sub-conversation-task-polling.ts | 72 +++ frontend/src/state/conversation-store.ts | 16 +- frontend/src/utils/status.ts | 9 +- frontend/src/utils/utils.ts | 16 + 9 files changed, 532 insertions(+), 71 deletions(-) create mode 100644 frontend/src/hooks/query/use-sub-conversation-task-polling.ts diff --git a/frontend/src/components/features/chat/change-agent-button.tsx b/frontend/src/components/features/chat/change-agent-button.tsx index e609c30b37..6257587963 100644 --- a/frontend/src/components/features/chat/change-agent-button.tsx +++ b/frontend/src/components/features/chat/change-agent-button.tsx @@ -15,17 +15,17 @@ import { useActiveConversation } from "#/hooks/query/use-active-conversation"; import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; import { displaySuccessToast } from "#/utils/custom-toast-handlers"; import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status"; +import { useSubConversationTaskPolling } from "#/hooks/query/use-sub-conversation-task-polling"; export function ChangeAgentButton() { const [contextMenuOpen, setContextMenuOpen] = useState(false); - const conversationMode = useConversationStore( - (state) => state.conversationMode, - ); - - const setConversationMode = useConversationStore( - (state) => state.setConversationMode, - ); + const { + conversationMode, + setConversationMode, + setSubConversationTaskId, + subConversationTaskId, + } = useConversationStore(); const webSocketStatus = useUnifiedWebSocketStatus(); @@ -43,6 +43,12 @@ export function ChangeAgentButton() { const { mutate: createConversation, isPending: isCreatingConversation } = useCreateConversation(); + // Poll sub-conversation task and invalidate parent conversation when ready + useSubConversationTaskPolling( + subConversationTaskId, + conversation?.conversation_id || null, + ); + // Close context menu when agent starts running useEffect(() => { if ((isAgentRunning || !isWebSocketConnected) && contextMenuOpen) { @@ -76,10 +82,15 @@ export function ChangeAgentButton() { agentType: "plan", }, { - onSuccess: () => + onSuccess: (data) => { displaySuccessToast( t(I18nKey.PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED), - ), + ); + // Track the task ID to poll for sub-conversation creation + if (data.v1_task_id) { + setSubConversationTaskId(data.v1_task_id); + } + }, }, ); }; diff --git a/frontend/src/components/features/chat/interactive-chat-box.tsx b/frontend/src/components/features/chat/interactive-chat-box.tsx index 4c94df4b42..56a4def14d 100644 --- a/frontend/src/components/features/chat/interactive-chat-box.tsx +++ b/frontend/src/components/features/chat/interactive-chat-box.tsx @@ -8,6 +8,8 @@ import { GitControlBar } from "./git-control-bar"; import { useConversationStore } from "#/state/conversation-store"; import { useAgentState } from "#/hooks/use-agent-state"; import { processFiles, processImages } from "#/utils/file-processing"; +import { useSubConversationTaskPolling } from "#/hooks/query/use-sub-conversation-task-polling"; +import { isTaskPolling } from "#/utils/utils"; interface InteractiveChatBoxProps { onSubmit: (message: string, images: File[], files: File[]) => void; @@ -24,10 +26,18 @@ export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) { removeFileLoading, addImageLoading, removeImageLoading, + subConversationTaskId, } = useConversationStore(); const { curAgentState } = useAgentState(); const { data: conversation } = useActiveConversation(); + // Poll sub-conversation task to check if it's loading + const { taskStatus: subConversationTaskStatus } = + useSubConversationTaskPolling( + subConversationTaskId, + conversation?.conversation_id || null, + ); + // Helper function to validate and filter files const validateAndFilterFiles = (selectedFiles: File[]) => { const validation = validateFiles(selectedFiles, [...images, ...files]); @@ -134,7 +144,8 @@ export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) { const isDisabled = curAgentState === AgentState.LOADING || - curAgentState === AgentState.AWAITING_USER_CONFIRMATION; + curAgentState === AgentState.AWAITING_USER_CONFIRMATION || + isTaskPolling(subConversationTaskStatus); return (
diff --git a/frontend/src/components/features/controls/agent-status.tsx b/frontend/src/components/features/controls/agent-status.tsx index 2fbff7192f..078eb5f40f 100644 --- a/frontend/src/components/features/controls/agent-status.tsx +++ b/frontend/src/components/features/controls/agent-status.tsx @@ -7,13 +7,14 @@ import { ChatStopButton } from "../chat/chat-stop-button"; import { AgentState } from "#/types/agent-state"; import ClockIcon from "#/icons/u-clock-three.svg?react"; import { ChatResumeAgentButton } from "../chat/chat-play-button"; -import { cn } from "#/utils/utils"; +import { cn, isTaskPolling } from "#/utils/utils"; import { AgentLoading } from "./agent-loading"; import { useConversationStore } from "#/state/conversation-store"; import CircleErrorIcon from "#/icons/circle-error.svg?react"; import { useAgentState } from "#/hooks/use-agent-state"; import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status"; import { useTaskPolling } from "#/hooks/query/use-task-polling"; +import { useSubConversationTaskPolling } from "#/hooks/query/use-sub-conversation-task-polling"; export interface AgentStatusProps { className?: string; @@ -38,6 +39,15 @@ export function AgentStatus({ const { data: conversation } = useActiveConversation(); const { taskStatus } = useTaskPolling(); + const { subConversationTaskId } = useConversationStore(); + + // Poll sub-conversation task to track its loading state + const { taskStatus: subConversationTaskStatus } = + useSubConversationTaskPolling( + subConversationTaskId, + conversation?.conversation_id || null, + ); + const statusCode = getStatusCode( curStatusMessage, webSocketStatus, @@ -45,17 +55,16 @@ export function AgentStatus({ conversation?.runtime_status || null, curAgentState, taskStatus, + subConversationTaskStatus, ); - const isTaskLoading = - taskStatus && taskStatus !== "ERROR" && taskStatus !== "READY"; - const shouldShownAgentLoading = isPausing || curAgentState === AgentState.INIT || curAgentState === AgentState.LOADING || (webSocketStatus === "CONNECTING" && taskStatus !== "ERROR") || - isTaskLoading; + isTaskPolling(taskStatus) || + isTaskPolling(subConversationTaskStatus); const shouldShownAgentError = curAgentState === AgentState.ERROR || diff --git a/frontend/src/contexts/conversation-websocket-context.tsx b/frontend/src/contexts/conversation-websocket-context.tsx index 7c492e5936..685f6c93ab 100644 --- a/frontend/src/contexts/conversation-websocket-context.tsx +++ b/frontend/src/contexts/conversation-websocket-context.tsx @@ -28,9 +28,13 @@ import { } from "#/types/v1/type-guards"; import { handleActionEventCacheInvalidation } from "#/utils/cache-utils"; import { buildWebSocketUrl } from "#/utils/websocket-url"; -import { isBudgetOrCreditError } from "#/utils/error-handler"; -import type { V1SendMessageRequest } from "#/api/conversation-service/v1-conversation-service.types"; +import type { + V1AppConversation, + V1SendMessageRequest, +} from "#/api/conversation-service/v1-conversation-service.types"; import EventService from "#/api/event-service/event-service.api"; +import { useConversationStore } from "#/state/conversation-store"; +import { isBudgetOrCreditError } from "#/utils/error-handler"; import { useTracking } from "#/hooks/use-tracking"; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -55,17 +59,27 @@ export function ConversationWebSocketProvider({ conversationId, conversationUrl, sessionApiKey, + subConversations, + subConversationIds, }: { children: React.ReactNode; conversationId?: string; conversationUrl?: string | null; sessionApiKey?: string | null; + subConversations?: V1AppConversation[]; + subConversationIds?: string[]; }) { - const [connectionState, setConnectionState] = + // Separate connection state tracking for each WebSocket + const [mainConnectionState, setMainConnectionState] = useState("CONNECTING"); - // Track if we've ever successfully connected + const [planningConnectionState, setPlanningConnectionState] = + useState("CONNECTING"); + + // Track if we've ever successfully connected for each connection // Don't show errors until after first successful connection - const hasConnectedRef = React.useRef(false); + const hasConnectedRefMain = React.useRef(false); + const hasConnectedRefPlanning = React.useRef(false); + const queryClient = useQueryClient(); const { addEvent } = useEventStore(); const { setErrorMessage, removeErrorMessage } = useErrorMessageStore(); @@ -74,12 +88,22 @@ export function ConversationWebSocketProvider({ const { appendInput, appendOutput } = useCommandStore(); const { trackCreditLimitReached } = useTracking(); - // History loading state - const [isLoadingHistory, setIsLoadingHistory] = useState(true); - const [expectedEventCount, setExpectedEventCount] = useState( - null, - ); - const receivedEventCountRef = useRef(0); + // History loading state - separate per connection + const [isLoadingHistoryMain, setIsLoadingHistoryMain] = useState(true); + const [isLoadingHistoryPlanning, setIsLoadingHistoryPlanning] = + useState(true); + const [expectedEventCountMain, setExpectedEventCountMain] = useState< + number | null + >(null); + const [expectedEventCountPlanning, setExpectedEventCountPlanning] = useState< + number | null + >(null); + + const { conversationMode } = useConversationStore(); + + // Separate received event count tracking per connection + const receivedEventCountRefMain = useRef(0); + const receivedEventCountRefPlanning = useRef(0); // Build WebSocket URL from props // Only build URL if we have both conversationId and conversationUrl @@ -92,40 +116,128 @@ export function ConversationWebSocketProvider({ return buildWebSocketUrl(conversationId, conversationUrl); }, [conversationId, conversationUrl]); - // Reset hasConnected flag and history loading state when conversation changes - useEffect(() => { - hasConnectedRef.current = false; - setIsLoadingHistory(true); - setExpectedEventCount(null); - receivedEventCountRef.current = 0; - }, [conversationId]); + const planningAgentWsUrl = useMemo(() => { + if (!subConversations?.length) { + return null; + } + + // Currently, there is only one sub-conversation and it uses the planning agent. + const planningAgentConversation = subConversations[0]; + + if ( + !planningAgentConversation?.id || + !planningAgentConversation.conversation_url + ) { + return null; + } + + return buildWebSocketUrl( + planningAgentConversation.id, + planningAgentConversation.conversation_url, + ); + }, [subConversations]); + + // Merged connection state - reflects combined status of both connections + const connectionState = useMemo(() => { + // If planning agent connection doesn't exist, use main connection state + if (!planningAgentWsUrl) { + return mainConnectionState; + } + + // If either is connecting, merged state is connecting + if ( + mainConnectionState === "CONNECTING" || + planningConnectionState === "CONNECTING" + ) { + return "CONNECTING"; + } + + // If both are open, merged state is open + if (mainConnectionState === "OPEN" && planningConnectionState === "OPEN") { + return "OPEN"; + } + + // If both are closed, merged state is closed + if ( + mainConnectionState === "CLOSED" && + planningConnectionState === "CLOSED" + ) { + return "CLOSED"; + } + + // If either is closing, merged state is closing + if ( + mainConnectionState === "CLOSING" || + planningConnectionState === "CLOSING" + ) { + return "CLOSING"; + } + + // Default to closed if states don't match expected patterns + return "CLOSED"; + }, [mainConnectionState, planningConnectionState, planningAgentWsUrl]); - // Check if we've received all events when expectedEventCount becomes available useEffect(() => { if ( - expectedEventCount !== null && - receivedEventCountRef.current >= expectedEventCount && - isLoadingHistory + expectedEventCountMain !== null && + receivedEventCountRefMain.current >= expectedEventCountMain && + isLoadingHistoryMain ) { - setIsLoadingHistory(false); + setIsLoadingHistoryMain(false); } - }, [expectedEventCount, isLoadingHistory]); + }, [expectedEventCountMain, isLoadingHistoryMain, receivedEventCountRefMain]); - const handleMessage = useCallback( + useEffect(() => { + if ( + expectedEventCountPlanning !== null && + receivedEventCountRefPlanning.current >= expectedEventCountPlanning && + isLoadingHistoryPlanning + ) { + setIsLoadingHistoryPlanning(false); + } + }, [ + expectedEventCountPlanning, + isLoadingHistoryPlanning, + receivedEventCountRefPlanning, + ]); + + useEffect(() => { + hasConnectedRefMain.current = false; + setIsLoadingHistoryPlanning(!!subConversationIds?.length); + setExpectedEventCountPlanning(null); + receivedEventCountRefPlanning.current = 0; + }, [subConversationIds]); + + // Merged loading history state - true if either connection is still loading + const isLoadingHistory = useMemo( + () => isLoadingHistoryMain || isLoadingHistoryPlanning, + [isLoadingHistoryMain, isLoadingHistoryPlanning], + ); + + // Reset hasConnected flags and history loading state when conversation changes + useEffect(() => { + hasConnectedRefPlanning.current = false; + setIsLoadingHistoryMain(true); + setExpectedEventCountMain(null); + receivedEventCountRefMain.current = 0; + }, [conversationId]); + + // Separate message handlers for each connection + const handleMainMessage = useCallback( (messageEvent: MessageEvent) => { try { const event = JSON.parse(messageEvent.data); // Track received events for history loading (count ALL events from WebSocket) // Always count when loading, even if we don't have the expected count yet - if (isLoadingHistory) { - receivedEventCountRef.current += 1; + if (isLoadingHistoryMain) { + receivedEventCountRefMain.current += 1; if ( - expectedEventCount !== null && - receivedEventCountRef.current >= expectedEventCount + expectedEventCountMain !== null && + receivedEventCountRefMain.current >= expectedEventCountMain ) { - setIsLoadingHistory(false); + setIsLoadingHistoryMain(false); } } @@ -199,8 +311,8 @@ export function ConversationWebSocketProvider({ }, [ addEvent, - isLoadingHistory, - expectedEventCount, + isLoadingHistoryMain, + expectedEventCountMain, setErrorMessage, removeOptimisticUserMessage, queryClient, @@ -211,7 +323,97 @@ export function ConversationWebSocketProvider({ ], ); - const websocketOptions: WebSocketHookOptions = useMemo(() => { + const handlePlanningMessage = useCallback( + (messageEvent: MessageEvent) => { + try { + const event = JSON.parse(messageEvent.data); + + // Track received events for history loading (count ALL events from WebSocket) + // Always count when loading, even if we don't have the expected count yet + if (isLoadingHistoryPlanning) { + receivedEventCountRefPlanning.current += 1; + + if ( + expectedEventCountPlanning !== null && + receivedEventCountRefPlanning.current >= expectedEventCountPlanning + ) { + setIsLoadingHistoryPlanning(false); + } + } + + // Use type guard to validate v1 event structure + if (isV1Event(event)) { + addEvent(event); + + // Handle AgentErrorEvent specifically + if (isAgentErrorEvent(event)) { + setErrorMessage(event.error); + } + + // Clear optimistic user message when a user message is confirmed + if (isUserMessageEvent(event)) { + removeOptimisticUserMessage(); + } + + // Handle cache invalidation for ActionEvent + if (isActionEvent(event)) { + const planningAgentConversation = subConversations?.[0]; + const currentConversationId = + planningAgentConversation?.id || "test-conversation-id"; // TODO: Get from context + handleActionEventCacheInvalidation( + event, + currentConversationId, + queryClient, + ); + } + + // Handle conversation state updates + // TODO: Tests + if (isConversationStateUpdateEvent(event)) { + if (isFullStateConversationStateUpdateEvent(event)) { + setExecutionStatus(event.value.execution_status); + } + if (isAgentStatusConversationStateUpdateEvent(event)) { + setExecutionStatus(event.value); + } + } + + // Handle ExecuteBashAction events - add command as input to terminal + if (isExecuteBashActionEvent(event)) { + appendInput(event.action.command); + } + + // Handle ExecuteBashObservation events - add output to terminal + if (isExecuteBashObservationEvent(event)) { + // Extract text content from the observation content array + const textContent = event.observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + appendOutput(textContent); + } + } + } catch (error) { + // eslint-disable-next-line no-console + console.warn("Failed to parse WebSocket message as JSON:", error); + } + }, + [ + addEvent, + isLoadingHistoryPlanning, + expectedEventCountPlanning, + setErrorMessage, + removeOptimisticUserMessage, + queryClient, + subConversations, + setExecutionStatus, + appendInput, + appendOutput, + ], + ); + + // Separate WebSocket options for main connection + const mainWebsocketOptions: WebSocketHookOptions = useMemo(() => { const queryParams: Record = { resend_all: true, }; @@ -225,57 +427,136 @@ export function ConversationWebSocketProvider({ queryParams, reconnect: { enabled: true }, onOpen: async () => { - setConnectionState("OPEN"); - hasConnectedRef.current = true; // Mark that we've successfully connected + setMainConnectionState("OPEN"); + hasConnectedRefMain.current = true; // Mark that we've successfully connected removeErrorMessage(); // Clear any previous error messages on successful connection // Fetch expected event count for history loading detection if (conversationId) { try { const count = await EventService.getEventCount(conversationId); - setExpectedEventCount(count); + setExpectedEventCountMain(count); // If no events expected, mark as loaded immediately if (count === 0) { - setIsLoadingHistory(false); + setIsLoadingHistoryMain(false); } } catch (error) { // Fall back to marking as loaded to avoid infinite loading state - setIsLoadingHistory(false); + setIsLoadingHistoryMain(false); } } }, onClose: (event: CloseEvent) => { - setConnectionState("CLOSED"); + setMainConnectionState("CLOSED"); // Only show error message if we've previously connected successfully // This prevents showing errors during initial connection attempts (e.g., when auto-starting a conversation) - if (event.code !== 1000 && hasConnectedRef.current) { + if (event.code !== 1000 && hasConnectedRefMain.current) { setErrorMessage( `Connection lost: ${event.reason || "Unexpected disconnect"}`, ); } }, onError: () => { - setConnectionState("CLOSED"); + setMainConnectionState("CLOSED"); // Only show error message if we've previously connected successfully - if (hasConnectedRef.current) { + if (hasConnectedRefMain.current) { setErrorMessage("Failed to connect to server"); } }, - onMessage: handleMessage, + onMessage: handleMainMessage, }; }, [ - handleMessage, + handleMainMessage, setErrorMessage, removeErrorMessage, sessionApiKey, conversationId, ]); + // Separate WebSocket options for planning agent connection + const planningWebsocketOptions: WebSocketHookOptions = useMemo(() => { + const queryParams: Record = { + resend_all: true, + }; + + // Add session_api_key if available + if (sessionApiKey) { + queryParams.session_api_key = sessionApiKey; + } + + const planningAgentConversation = subConversations?.[0]; + + return { + queryParams, + reconnect: { enabled: true }, + onOpen: async () => { + setPlanningConnectionState("OPEN"); + hasConnectedRefPlanning.current = true; // Mark that we've successfully connected + removeErrorMessage(); // Clear any previous error messages on successful connection + + // Fetch expected event count for history loading detection + if (planningAgentConversation?.id) { + try { + const count = await EventService.getEventCount( + planningAgentConversation.id, + ); + setExpectedEventCountPlanning(count); + + // If no events expected, mark as loaded immediately + if (count === 0) { + setIsLoadingHistoryPlanning(false); + } + } catch (error) { + // Fall back to marking as loaded to avoid infinite loading state + setIsLoadingHistoryPlanning(false); + } + } + }, + onClose: (event: CloseEvent) => { + setPlanningConnectionState("CLOSED"); + // Only show error message if we've previously connected successfully + // This prevents showing errors during initial connection attempts (e.g., when auto-starting a conversation) + if (event.code !== 1000 && hasConnectedRefPlanning.current) { + setErrorMessage( + `Connection lost: ${event.reason || "Unexpected disconnect"}`, + ); + } + }, + onError: () => { + setPlanningConnectionState("CLOSED"); + // Only show error message if we've previously connected successfully + if (hasConnectedRefPlanning.current) { + setErrorMessage("Failed to connect to server"); + } + }, + onMessage: handlePlanningMessage, + }; + }, [ + handlePlanningMessage, + setErrorMessage, + removeErrorMessage, + sessionApiKey, + subConversations, + ]); + // Only attempt WebSocket connection when we have a valid URL // This prevents connection attempts during task polling phase const websocketUrl = wsUrl; - const { socket } = useWebSocket(websocketUrl || "", websocketOptions); + const { socket: mainSocket } = useWebSocket( + websocketUrl || "", + mainWebsocketOptions, + ); + + const { socket: planningAgentSocket } = useWebSocket( + planningAgentWsUrl || "", + planningWebsocketOptions, + ); + + const socket = useMemo( + () => (conversationMode === "plan" ? planningAgentSocket : mainSocket), + [conversationMode, planningAgentSocket, mainSocket], + ); // V1 send message function via WebSocket const sendMessage = useCallback( @@ -299,33 +580,63 @@ export function ConversationWebSocketProvider({ [socket, setErrorMessage], ); + // Track main socket state changes useEffect(() => { // Only process socket updates if we have a valid URL and socket - if (socket && wsUrl) { + if (mainSocket && wsUrl) { // Update state based on socket readyState const updateState = () => { - switch (socket.readyState) { + switch (mainSocket.readyState) { case WebSocket.CONNECTING: - setConnectionState("CONNECTING"); + setMainConnectionState("CONNECTING"); break; case WebSocket.OPEN: - setConnectionState("OPEN"); + setMainConnectionState("OPEN"); break; case WebSocket.CLOSING: - setConnectionState("CLOSING"); + setMainConnectionState("CLOSING"); break; case WebSocket.CLOSED: - setConnectionState("CLOSED"); + setMainConnectionState("CLOSED"); break; default: - setConnectionState("CLOSED"); + setMainConnectionState("CLOSED"); break; } }; updateState(); } - }, [socket, wsUrl]); + }, [mainSocket, wsUrl]); + + // Track planning agent socket state changes + useEffect(() => { + // Only process socket updates if we have a valid URL and socket + if (planningAgentSocket && planningAgentWsUrl) { + // Update state based on socket readyState + const updateState = () => { + switch (planningAgentSocket.readyState) { + case WebSocket.CONNECTING: + setPlanningConnectionState("CONNECTING"); + break; + case WebSocket.OPEN: + setPlanningConnectionState("OPEN"); + break; + case WebSocket.CLOSING: + setPlanningConnectionState("CLOSING"); + break; + case WebSocket.CLOSED: + setPlanningConnectionState("CLOSED"); + break; + default: + setPlanningConnectionState("CLOSED"); + break; + } + }; + + updateState(); + } + }, [planningAgentSocket, planningAgentWsUrl]); const contextValue = useMemo( () => ({ connectionState, sendMessage, isLoadingHistory }), diff --git a/frontend/src/contexts/websocket-provider-wrapper.tsx b/frontend/src/contexts/websocket-provider-wrapper.tsx index bf2a28d6b0..d278a46551 100644 --- a/frontend/src/contexts/websocket-provider-wrapper.tsx +++ b/frontend/src/contexts/websocket-provider-wrapper.tsx @@ -2,6 +2,7 @@ import React from "react"; import { WsClientProvider } from "#/context/ws-client-provider"; import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context"; import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useSubConversations } from "#/hooks/query/use-sub-conversations"; interface WebSocketProviderWrapperProps { children: React.ReactNode; @@ -36,6 +37,15 @@ export function WebSocketProviderWrapper({ }: WebSocketProviderWrapperProps) { // Get conversation data for V1 provider const { data: conversation } = useActiveConversation(); + // Get sub-conversation data for V1 provider + const { data: subConversations } = useSubConversations( + conversation?.sub_conversation_ids ?? [], + ); + + // Filter out null sub-conversations + const filteredSubConversations = subConversations?.filter( + (subConversation) => subConversation !== null, + ); if (version === 0) { return ( @@ -51,6 +61,8 @@ export function WebSocketProviderWrapper({ conversationId={conversationId} conversationUrl={conversation?.url} sessionApiKey={conversation?.session_api_key} + subConversationIds={conversation?.sub_conversation_ids} + subConversations={filteredSubConversations} > {children} diff --git a/frontend/src/hooks/query/use-sub-conversation-task-polling.ts b/frontend/src/hooks/query/use-sub-conversation-task-polling.ts new file mode 100644 index 0000000000..e7dd29aae0 --- /dev/null +++ b/frontend/src/hooks/query/use-sub-conversation-task-polling.ts @@ -0,0 +1,72 @@ +import { useEffect } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api"; + +/** + * Hook that polls V1 sub-conversation start tasks and invalidates parent conversation cache when ready. + * + * This hook: + * - Polls the V1 start task API every 3 seconds until status is READY or ERROR + * - Automatically invalidates the parent conversation cache when the task becomes READY + * - Exposes task status and details for UI components to show loading states and errors + * + * Use case: + * - When creating a sub-conversation (e.g., plan mode), track the task and refresh parent conversation + * data once the sub-conversation is ready + * + * @param taskId - The task ID to poll (from createConversation response) + * @param parentConversationId - The parent conversation ID to invalidate when ready + */ +export const useSubConversationTaskPolling = ( + taskId: string | null, + parentConversationId: string | null, +) => { + const queryClient = useQueryClient(); + + // Poll the task if we have both taskId and parentConversationId + const taskQuery = useQuery({ + queryKey: ["sub-conversation-task", taskId], + queryFn: async () => { + if (!taskId) return null; + return V1ConversationService.getStartTask(taskId); + }, + enabled: !!taskId && !!parentConversationId, + refetchInterval: (query) => { + const task = query.state.data; + if (!task) return false; + + // Stop polling if ready or error + if (task.status === "READY" || task.status === "ERROR") { + return false; + } + + // Poll every 3 seconds while task is in progress + return 3000; + }, + retry: false, + }); + + // Invalidate parent conversation cache when task is ready + useEffect(() => { + const task = taskQuery.data; + if ( + task?.status === "READY" && + task.app_conversation_id && + parentConversationId + ) { + // Invalidate the parent conversation to refetch with updated sub_conversation_ids + queryClient.invalidateQueries({ + queryKey: ["user", "conversation", parentConversationId], + }); + } + }, [taskQuery.data, parentConversationId, queryClient]); + + return { + task: taskQuery.data, + taskStatus: taskQuery.data?.status, + taskDetail: taskQuery.data?.detail, + taskError: taskQuery.error, + isLoadingTask: taskQuery.isLoading, + subConversationId: taskQuery.data?.app_conversation_id, + }; +}; diff --git a/frontend/src/state/conversation-store.ts b/frontend/src/state/conversation-store.ts index fc6868cc1a..77186ce69c 100644 --- a/frontend/src/state/conversation-store.ts +++ b/frontend/src/state/conversation-store.ts @@ -30,6 +30,7 @@ interface ConversationState { hasRightPanelToggled: boolean; planContent: string | null; conversationMode: ConversationMode; + subConversationTaskId: string | null; // Task ID for sub-conversation creation } interface ConversationActions { @@ -54,6 +55,7 @@ interface ConversationActions { resetConversationState: () => void; setHasRightPanelToggled: (hasRightPanelToggled: boolean) => void; setConversationMode: (conversationMode: ConversationMode) => void; + setSubConversationTaskId: (taskId: string | null) => void; } type ConversationStore = ConversationState & ConversationActions; @@ -165,6 +167,7 @@ The model took too long to respond - Simplify the task - Check model server logs`, conversationMode: "code", + subConversationTaskId: null, // Actions setIsRightPanelShown: (isRightPanelShown) => @@ -296,13 +299,24 @@ The model took too long to respond set({ submittedMessage }, false, "setSubmittedMessage"), resetConversationState: () => - set({ shouldHideSuggestions: false }, false, "resetConversationState"), + set( + { + shouldHideSuggestions: false, + conversationMode: "code", + subConversationTaskId: null, + }, + false, + "resetConversationState", + ), setHasRightPanelToggled: (hasRightPanelToggled) => set({ hasRightPanelToggled }, false, "setHasRightPanelToggled"), setConversationMode: (conversationMode) => set({ conversationMode }, false, "setConversationMode"), + + setSubConversationTaskId: (subConversationTaskId) => + set({ subConversationTaskId }, false, "setSubConversationTaskId"), }), { name: "conversation-store", diff --git a/frontend/src/utils/status.ts b/frontend/src/utils/status.ts index 7b5ef5a126..e64820b291 100644 --- a/frontend/src/utils/status.ts +++ b/frontend/src/utils/status.ts @@ -5,6 +5,7 @@ import { ConversationStatus } from "#/types/conversation-status"; import { StatusMessage } from "#/types/message"; import { RuntimeStatus } from "#/types/runtime-status"; import { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types"; +import { isTaskPolling } from "./utils"; export enum IndicatorColor { BLUE = "bg-blue-500", @@ -105,10 +106,11 @@ export function getStatusCode( runtimeStatus: RuntimeStatus | null, agentState: AgentState | null, taskStatus?: V1AppConversationStartTaskStatus | null, + subConversationTaskStatus?: V1AppConversationStartTaskStatus | null, ) { // PRIORITY 1: Handle task error state (when start-tasks API returns ERROR) // This must come first to prevent "Connecting..." from showing when task has errored - if (taskStatus === "ERROR") { + if (taskStatus === "ERROR" || subConversationTaskStatus === "ERROR") { return I18nKey.AGENT_STATUS$ERROR_OCCURRED; } @@ -147,7 +149,10 @@ export function getStatusCode( if (webSocketStatus === "DISCONNECTED") { return I18nKey.CHAT_INTERFACE$DISCONNECTED; } - if (webSocketStatus === "CONNECTING") { + if ( + webSocketStatus === "CONNECTING" || + isTaskPolling(subConversationTaskStatus) + ) { return I18nKey.CHAT_INTERFACE$CONNECTING; } diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index a7fe39e463..bd915b9f6c 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -611,6 +611,22 @@ export const buildSessionHeaders = ( return headers; }; +/** + * Check if a task is currently being polled (loading state) + * @param taskStatus The task status string (e.g., "WORKING", "ERROR", "READY") + * @returns True if the task is in a loading state (not ERROR and not READY) + * + * @example + * isTaskPolling("WORKING") // Returns true + * isTaskPolling("PREPARING_REPOSITORY") // Returns true + * isTaskPolling("READY") // Returns false + * isTaskPolling("ERROR") // Returns false + * isTaskPolling(null) // Returns false + * isTaskPolling(undefined) // Returns false + */ +export const isTaskPolling = (taskStatus: string | null | undefined): boolean => + !!taskStatus && taskStatus !== "ERROR" && taskStatus !== "READY"; + /** * Get the appropriate color based on agent status * @param options Configuration object for status color calculation