mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
feat(frontend): integration of events from execution and planning agents within a single conversation (#11786)
This commit is contained in:
parent
c82e183066
commit
d1d08bc490
@ -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<boolean>(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);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 (
|
||||
<div data-testid="interactive-chat-box">
|
||||
|
||||
@ -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 ||
|
||||
|
||||
@ -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<V1_WebSocketConnectionState>("CONNECTING");
|
||||
// Track if we've ever successfully connected
|
||||
const [planningConnectionState, setPlanningConnectionState] =
|
||||
useState<V1_WebSocketConnectionState>("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<number | null>(
|
||||
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<V1_WebSocketConnectionState>(() => {
|
||||
// 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<string, string | boolean> = {
|
||||
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<string, string | boolean> = {
|
||||
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 }),
|
||||
|
||||
@ -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}
|
||||
</ConversationWebSocketProvider>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user