feat(frontend): integration of events from execution and planning agents within a single conversation (#11786)

This commit is contained in:
Hiep Le 2025-11-20 21:21:46 +07:00 committed by GitHub
parent c82e183066
commit d1d08bc490
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 532 additions and 71 deletions

View File

@ -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);
}
},
},
);
};

View File

@ -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">

View File

@ -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 ||

View File

@ -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 }),

View File

@ -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>

View File

@ -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,
};
};

View File

@ -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",

View File

@ -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;
}

View File

@ -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