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 { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||||
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||||
import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status";
|
import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status";
|
||||||
|
import { useSubConversationTaskPolling } from "#/hooks/query/use-sub-conversation-task-polling";
|
||||||
|
|
||||||
export function ChangeAgentButton() {
|
export function ChangeAgentButton() {
|
||||||
const [contextMenuOpen, setContextMenuOpen] = useState<boolean>(false);
|
const [contextMenuOpen, setContextMenuOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
const conversationMode = useConversationStore(
|
const {
|
||||||
(state) => state.conversationMode,
|
conversationMode,
|
||||||
);
|
setConversationMode,
|
||||||
|
setSubConversationTaskId,
|
||||||
const setConversationMode = useConversationStore(
|
subConversationTaskId,
|
||||||
(state) => state.setConversationMode,
|
} = useConversationStore();
|
||||||
);
|
|
||||||
|
|
||||||
const webSocketStatus = useUnifiedWebSocketStatus();
|
const webSocketStatus = useUnifiedWebSocketStatus();
|
||||||
|
|
||||||
@ -43,6 +43,12 @@ export function ChangeAgentButton() {
|
|||||||
const { mutate: createConversation, isPending: isCreatingConversation } =
|
const { mutate: createConversation, isPending: isCreatingConversation } =
|
||||||
useCreateConversation();
|
useCreateConversation();
|
||||||
|
|
||||||
|
// Poll sub-conversation task and invalidate parent conversation when ready
|
||||||
|
useSubConversationTaskPolling(
|
||||||
|
subConversationTaskId,
|
||||||
|
conversation?.conversation_id || null,
|
||||||
|
);
|
||||||
|
|
||||||
// Close context menu when agent starts running
|
// Close context menu when agent starts running
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((isAgentRunning || !isWebSocketConnected) && contextMenuOpen) {
|
if ((isAgentRunning || !isWebSocketConnected) && contextMenuOpen) {
|
||||||
@ -76,10 +82,15 @@ export function ChangeAgentButton() {
|
|||||||
agentType: "plan",
|
agentType: "plan",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () =>
|
onSuccess: (data) => {
|
||||||
displaySuccessToast(
|
displaySuccessToast(
|
||||||
t(I18nKey.PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED),
|
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 { useConversationStore } from "#/state/conversation-store";
|
||||||
import { useAgentState } from "#/hooks/use-agent-state";
|
import { useAgentState } from "#/hooks/use-agent-state";
|
||||||
import { processFiles, processImages } from "#/utils/file-processing";
|
import { processFiles, processImages } from "#/utils/file-processing";
|
||||||
|
import { useSubConversationTaskPolling } from "#/hooks/query/use-sub-conversation-task-polling";
|
||||||
|
import { isTaskPolling } from "#/utils/utils";
|
||||||
|
|
||||||
interface InteractiveChatBoxProps {
|
interface InteractiveChatBoxProps {
|
||||||
onSubmit: (message: string, images: File[], files: File[]) => void;
|
onSubmit: (message: string, images: File[], files: File[]) => void;
|
||||||
@ -24,10 +26,18 @@ export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) {
|
|||||||
removeFileLoading,
|
removeFileLoading,
|
||||||
addImageLoading,
|
addImageLoading,
|
||||||
removeImageLoading,
|
removeImageLoading,
|
||||||
|
subConversationTaskId,
|
||||||
} = useConversationStore();
|
} = useConversationStore();
|
||||||
const { curAgentState } = useAgentState();
|
const { curAgentState } = useAgentState();
|
||||||
const { data: conversation } = useActiveConversation();
|
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
|
// Helper function to validate and filter files
|
||||||
const validateAndFilterFiles = (selectedFiles: File[]) => {
|
const validateAndFilterFiles = (selectedFiles: File[]) => {
|
||||||
const validation = validateFiles(selectedFiles, [...images, ...files]);
|
const validation = validateFiles(selectedFiles, [...images, ...files]);
|
||||||
@ -134,7 +144,8 @@ export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) {
|
|||||||
|
|
||||||
const isDisabled =
|
const isDisabled =
|
||||||
curAgentState === AgentState.LOADING ||
|
curAgentState === AgentState.LOADING ||
|
||||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION;
|
curAgentState === AgentState.AWAITING_USER_CONFIRMATION ||
|
||||||
|
isTaskPolling(subConversationTaskStatus);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="interactive-chat-box">
|
<div data-testid="interactive-chat-box">
|
||||||
|
|||||||
@ -7,13 +7,14 @@ import { ChatStopButton } from "../chat/chat-stop-button";
|
|||||||
import { AgentState } from "#/types/agent-state";
|
import { AgentState } from "#/types/agent-state";
|
||||||
import ClockIcon from "#/icons/u-clock-three.svg?react";
|
import ClockIcon from "#/icons/u-clock-three.svg?react";
|
||||||
import { ChatResumeAgentButton } from "../chat/chat-play-button";
|
import { ChatResumeAgentButton } from "../chat/chat-play-button";
|
||||||
import { cn } from "#/utils/utils";
|
import { cn, isTaskPolling } from "#/utils/utils";
|
||||||
import { AgentLoading } from "./agent-loading";
|
import { AgentLoading } from "./agent-loading";
|
||||||
import { useConversationStore } from "#/state/conversation-store";
|
import { useConversationStore } from "#/state/conversation-store";
|
||||||
import CircleErrorIcon from "#/icons/circle-error.svg?react";
|
import CircleErrorIcon from "#/icons/circle-error.svg?react";
|
||||||
import { useAgentState } from "#/hooks/use-agent-state";
|
import { useAgentState } from "#/hooks/use-agent-state";
|
||||||
import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status";
|
import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status";
|
||||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||||
|
import { useSubConversationTaskPolling } from "#/hooks/query/use-sub-conversation-task-polling";
|
||||||
|
|
||||||
export interface AgentStatusProps {
|
export interface AgentStatusProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -38,6 +39,15 @@ export function AgentStatus({
|
|||||||
const { data: conversation } = useActiveConversation();
|
const { data: conversation } = useActiveConversation();
|
||||||
const { taskStatus } = useTaskPolling();
|
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(
|
const statusCode = getStatusCode(
|
||||||
curStatusMessage,
|
curStatusMessage,
|
||||||
webSocketStatus,
|
webSocketStatus,
|
||||||
@ -45,17 +55,16 @@ export function AgentStatus({
|
|||||||
conversation?.runtime_status || null,
|
conversation?.runtime_status || null,
|
||||||
curAgentState,
|
curAgentState,
|
||||||
taskStatus,
|
taskStatus,
|
||||||
|
subConversationTaskStatus,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isTaskLoading =
|
|
||||||
taskStatus && taskStatus !== "ERROR" && taskStatus !== "READY";
|
|
||||||
|
|
||||||
const shouldShownAgentLoading =
|
const shouldShownAgentLoading =
|
||||||
isPausing ||
|
isPausing ||
|
||||||
curAgentState === AgentState.INIT ||
|
curAgentState === AgentState.INIT ||
|
||||||
curAgentState === AgentState.LOADING ||
|
curAgentState === AgentState.LOADING ||
|
||||||
(webSocketStatus === "CONNECTING" && taskStatus !== "ERROR") ||
|
(webSocketStatus === "CONNECTING" && taskStatus !== "ERROR") ||
|
||||||
isTaskLoading;
|
isTaskPolling(taskStatus) ||
|
||||||
|
isTaskPolling(subConversationTaskStatus);
|
||||||
|
|
||||||
const shouldShownAgentError =
|
const shouldShownAgentError =
|
||||||
curAgentState === AgentState.ERROR ||
|
curAgentState === AgentState.ERROR ||
|
||||||
|
|||||||
@ -28,9 +28,13 @@ import {
|
|||||||
} from "#/types/v1/type-guards";
|
} from "#/types/v1/type-guards";
|
||||||
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
|
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
|
||||||
import { buildWebSocketUrl } from "#/utils/websocket-url";
|
import { buildWebSocketUrl } from "#/utils/websocket-url";
|
||||||
import { isBudgetOrCreditError } from "#/utils/error-handler";
|
import type {
|
||||||
import type { V1SendMessageRequest } from "#/api/conversation-service/v1-conversation-service.types";
|
V1AppConversation,
|
||||||
|
V1SendMessageRequest,
|
||||||
|
} from "#/api/conversation-service/v1-conversation-service.types";
|
||||||
import EventService from "#/api/event-service/event-service.api";
|
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";
|
import { useTracking } from "#/hooks/use-tracking";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
@ -55,17 +59,27 @@ export function ConversationWebSocketProvider({
|
|||||||
conversationId,
|
conversationId,
|
||||||
conversationUrl,
|
conversationUrl,
|
||||||
sessionApiKey,
|
sessionApiKey,
|
||||||
|
subConversations,
|
||||||
|
subConversationIds,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
conversationUrl?: string | null;
|
conversationUrl?: string | null;
|
||||||
sessionApiKey?: 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");
|
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
|
// 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 queryClient = useQueryClient();
|
||||||
const { addEvent } = useEventStore();
|
const { addEvent } = useEventStore();
|
||||||
const { setErrorMessage, removeErrorMessage } = useErrorMessageStore();
|
const { setErrorMessage, removeErrorMessage } = useErrorMessageStore();
|
||||||
@ -74,12 +88,22 @@ export function ConversationWebSocketProvider({
|
|||||||
const { appendInput, appendOutput } = useCommandStore();
|
const { appendInput, appendOutput } = useCommandStore();
|
||||||
const { trackCreditLimitReached } = useTracking();
|
const { trackCreditLimitReached } = useTracking();
|
||||||
|
|
||||||
// History loading state
|
// History loading state - separate per connection
|
||||||
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
|
const [isLoadingHistoryMain, setIsLoadingHistoryMain] = useState(true);
|
||||||
const [expectedEventCount, setExpectedEventCount] = useState<number | null>(
|
const [isLoadingHistoryPlanning, setIsLoadingHistoryPlanning] =
|
||||||
null,
|
useState(true);
|
||||||
);
|
const [expectedEventCountMain, setExpectedEventCountMain] = useState<
|
||||||
const receivedEventCountRef = useRef(0);
|
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
|
// Build WebSocket URL from props
|
||||||
// Only build URL if we have both conversationId and conversationUrl
|
// Only build URL if we have both conversationId and conversationUrl
|
||||||
@ -92,40 +116,128 @@ export function ConversationWebSocketProvider({
|
|||||||
return buildWebSocketUrl(conversationId, conversationUrl);
|
return buildWebSocketUrl(conversationId, conversationUrl);
|
||||||
}, [conversationId, conversationUrl]);
|
}, [conversationId, conversationUrl]);
|
||||||
|
|
||||||
// Reset hasConnected flag and history loading state when conversation changes
|
const planningAgentWsUrl = useMemo(() => {
|
||||||
useEffect(() => {
|
if (!subConversations?.length) {
|
||||||
hasConnectedRef.current = false;
|
return null;
|
||||||
setIsLoadingHistory(true);
|
}
|
||||||
setExpectedEventCount(null);
|
|
||||||
receivedEventCountRef.current = 0;
|
// Currently, there is only one sub-conversation and it uses the planning agent.
|
||||||
}, [conversationId]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
expectedEventCount !== null &&
|
expectedEventCountMain !== null &&
|
||||||
receivedEventCountRef.current >= expectedEventCount &&
|
receivedEventCountRefMain.current >= expectedEventCountMain &&
|
||||||
isLoadingHistory
|
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) => {
|
(messageEvent: MessageEvent) => {
|
||||||
try {
|
try {
|
||||||
const event = JSON.parse(messageEvent.data);
|
const event = JSON.parse(messageEvent.data);
|
||||||
|
|
||||||
// Track received events for history loading (count ALL events from WebSocket)
|
// 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
|
// Always count when loading, even if we don't have the expected count yet
|
||||||
if (isLoadingHistory) {
|
if (isLoadingHistoryMain) {
|
||||||
receivedEventCountRef.current += 1;
|
receivedEventCountRefMain.current += 1;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
expectedEventCount !== null &&
|
expectedEventCountMain !== null &&
|
||||||
receivedEventCountRef.current >= expectedEventCount
|
receivedEventCountRefMain.current >= expectedEventCountMain
|
||||||
) {
|
) {
|
||||||
setIsLoadingHistory(false);
|
setIsLoadingHistoryMain(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -199,8 +311,8 @@ export function ConversationWebSocketProvider({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
addEvent,
|
addEvent,
|
||||||
isLoadingHistory,
|
isLoadingHistoryMain,
|
||||||
expectedEventCount,
|
expectedEventCountMain,
|
||||||
setErrorMessage,
|
setErrorMessage,
|
||||||
removeOptimisticUserMessage,
|
removeOptimisticUserMessage,
|
||||||
queryClient,
|
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> = {
|
const queryParams: Record<string, string | boolean> = {
|
||||||
resend_all: true,
|
resend_all: true,
|
||||||
};
|
};
|
||||||
@ -225,57 +427,136 @@ export function ConversationWebSocketProvider({
|
|||||||
queryParams,
|
queryParams,
|
||||||
reconnect: { enabled: true },
|
reconnect: { enabled: true },
|
||||||
onOpen: async () => {
|
onOpen: async () => {
|
||||||
setConnectionState("OPEN");
|
setMainConnectionState("OPEN");
|
||||||
hasConnectedRef.current = true; // Mark that we've successfully connected
|
hasConnectedRefMain.current = true; // Mark that we've successfully connected
|
||||||
removeErrorMessage(); // Clear any previous error messages on successful connection
|
removeErrorMessage(); // Clear any previous error messages on successful connection
|
||||||
|
|
||||||
// Fetch expected event count for history loading detection
|
// Fetch expected event count for history loading detection
|
||||||
if (conversationId) {
|
if (conversationId) {
|
||||||
try {
|
try {
|
||||||
const count = await EventService.getEventCount(conversationId);
|
const count = await EventService.getEventCount(conversationId);
|
||||||
setExpectedEventCount(count);
|
setExpectedEventCountMain(count);
|
||||||
|
|
||||||
// If no events expected, mark as loaded immediately
|
// If no events expected, mark as loaded immediately
|
||||||
if (count === 0) {
|
if (count === 0) {
|
||||||
setIsLoadingHistory(false);
|
setIsLoadingHistoryMain(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Fall back to marking as loaded to avoid infinite loading state
|
// Fall back to marking as loaded to avoid infinite loading state
|
||||||
setIsLoadingHistory(false);
|
setIsLoadingHistoryMain(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onClose: (event: CloseEvent) => {
|
onClose: (event: CloseEvent) => {
|
||||||
setConnectionState("CLOSED");
|
setMainConnectionState("CLOSED");
|
||||||
// Only show error message if we've previously connected successfully
|
// 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)
|
// 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(
|
setErrorMessage(
|
||||||
`Connection lost: ${event.reason || "Unexpected disconnect"}`,
|
`Connection lost: ${event.reason || "Unexpected disconnect"}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
setConnectionState("CLOSED");
|
setMainConnectionState("CLOSED");
|
||||||
// Only show error message if we've previously connected successfully
|
// Only show error message if we've previously connected successfully
|
||||||
if (hasConnectedRef.current) {
|
if (hasConnectedRefMain.current) {
|
||||||
setErrorMessage("Failed to connect to server");
|
setErrorMessage("Failed to connect to server");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMessage: handleMessage,
|
onMessage: handleMainMessage,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
handleMessage,
|
handleMainMessage,
|
||||||
setErrorMessage,
|
setErrorMessage,
|
||||||
removeErrorMessage,
|
removeErrorMessage,
|
||||||
sessionApiKey,
|
sessionApiKey,
|
||||||
conversationId,
|
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
|
// Only attempt WebSocket connection when we have a valid URL
|
||||||
// This prevents connection attempts during task polling phase
|
// This prevents connection attempts during task polling phase
|
||||||
const websocketUrl = wsUrl;
|
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
|
// V1 send message function via WebSocket
|
||||||
const sendMessage = useCallback(
|
const sendMessage = useCallback(
|
||||||
@ -299,33 +580,63 @@ export function ConversationWebSocketProvider({
|
|||||||
[socket, setErrorMessage],
|
[socket, setErrorMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Track main socket state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only process socket updates if we have a valid URL and socket
|
// Only process socket updates if we have a valid URL and socket
|
||||||
if (socket && wsUrl) {
|
if (mainSocket && wsUrl) {
|
||||||
// Update state based on socket readyState
|
// Update state based on socket readyState
|
||||||
const updateState = () => {
|
const updateState = () => {
|
||||||
switch (socket.readyState) {
|
switch (mainSocket.readyState) {
|
||||||
case WebSocket.CONNECTING:
|
case WebSocket.CONNECTING:
|
||||||
setConnectionState("CONNECTING");
|
setMainConnectionState("CONNECTING");
|
||||||
break;
|
break;
|
||||||
case WebSocket.OPEN:
|
case WebSocket.OPEN:
|
||||||
setConnectionState("OPEN");
|
setMainConnectionState("OPEN");
|
||||||
break;
|
break;
|
||||||
case WebSocket.CLOSING:
|
case WebSocket.CLOSING:
|
||||||
setConnectionState("CLOSING");
|
setMainConnectionState("CLOSING");
|
||||||
break;
|
break;
|
||||||
case WebSocket.CLOSED:
|
case WebSocket.CLOSED:
|
||||||
setConnectionState("CLOSED");
|
setMainConnectionState("CLOSED");
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
setConnectionState("CLOSED");
|
setMainConnectionState("CLOSED");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
updateState();
|
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(
|
const contextValue = useMemo(
|
||||||
() => ({ connectionState, sendMessage, isLoadingHistory }),
|
() => ({ connectionState, sendMessage, isLoadingHistory }),
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import React from "react";
|
|||||||
import { WsClientProvider } from "#/context/ws-client-provider";
|
import { WsClientProvider } from "#/context/ws-client-provider";
|
||||||
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
|
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
|
||||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||||
|
import { useSubConversations } from "#/hooks/query/use-sub-conversations";
|
||||||
|
|
||||||
interface WebSocketProviderWrapperProps {
|
interface WebSocketProviderWrapperProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -36,6 +37,15 @@ export function WebSocketProviderWrapper({
|
|||||||
}: WebSocketProviderWrapperProps) {
|
}: WebSocketProviderWrapperProps) {
|
||||||
// Get conversation data for V1 provider
|
// Get conversation data for V1 provider
|
||||||
const { data: conversation } = useActiveConversation();
|
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) {
|
if (version === 0) {
|
||||||
return (
|
return (
|
||||||
@ -51,6 +61,8 @@ export function WebSocketProviderWrapper({
|
|||||||
conversationId={conversationId}
|
conversationId={conversationId}
|
||||||
conversationUrl={conversation?.url}
|
conversationUrl={conversation?.url}
|
||||||
sessionApiKey={conversation?.session_api_key}
|
sessionApiKey={conversation?.session_api_key}
|
||||||
|
subConversationIds={conversation?.sub_conversation_ids}
|
||||||
|
subConversations={filteredSubConversations}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ConversationWebSocketProvider>
|
</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;
|
hasRightPanelToggled: boolean;
|
||||||
planContent: string | null;
|
planContent: string | null;
|
||||||
conversationMode: ConversationMode;
|
conversationMode: ConversationMode;
|
||||||
|
subConversationTaskId: string | null; // Task ID for sub-conversation creation
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConversationActions {
|
interface ConversationActions {
|
||||||
@ -54,6 +55,7 @@ interface ConversationActions {
|
|||||||
resetConversationState: () => void;
|
resetConversationState: () => void;
|
||||||
setHasRightPanelToggled: (hasRightPanelToggled: boolean) => void;
|
setHasRightPanelToggled: (hasRightPanelToggled: boolean) => void;
|
||||||
setConversationMode: (conversationMode: ConversationMode) => void;
|
setConversationMode: (conversationMode: ConversationMode) => void;
|
||||||
|
setSubConversationTaskId: (taskId: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConversationStore = ConversationState & ConversationActions;
|
type ConversationStore = ConversationState & ConversationActions;
|
||||||
@ -165,6 +167,7 @@ The model took too long to respond
|
|||||||
- Simplify the task
|
- Simplify the task
|
||||||
- Check model server logs`,
|
- Check model server logs`,
|
||||||
conversationMode: "code",
|
conversationMode: "code",
|
||||||
|
subConversationTaskId: null,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setIsRightPanelShown: (isRightPanelShown) =>
|
setIsRightPanelShown: (isRightPanelShown) =>
|
||||||
@ -296,13 +299,24 @@ The model took too long to respond
|
|||||||
set({ submittedMessage }, false, "setSubmittedMessage"),
|
set({ submittedMessage }, false, "setSubmittedMessage"),
|
||||||
|
|
||||||
resetConversationState: () =>
|
resetConversationState: () =>
|
||||||
set({ shouldHideSuggestions: false }, false, "resetConversationState"),
|
set(
|
||||||
|
{
|
||||||
|
shouldHideSuggestions: false,
|
||||||
|
conversationMode: "code",
|
||||||
|
subConversationTaskId: null,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
"resetConversationState",
|
||||||
|
),
|
||||||
|
|
||||||
setHasRightPanelToggled: (hasRightPanelToggled) =>
|
setHasRightPanelToggled: (hasRightPanelToggled) =>
|
||||||
set({ hasRightPanelToggled }, false, "setHasRightPanelToggled"),
|
set({ hasRightPanelToggled }, false, "setHasRightPanelToggled"),
|
||||||
|
|
||||||
setConversationMode: (conversationMode) =>
|
setConversationMode: (conversationMode) =>
|
||||||
set({ conversationMode }, false, "setConversationMode"),
|
set({ conversationMode }, false, "setConversationMode"),
|
||||||
|
|
||||||
|
setSubConversationTaskId: (subConversationTaskId) =>
|
||||||
|
set({ subConversationTaskId }, false, "setSubConversationTaskId"),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "conversation-store",
|
name: "conversation-store",
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { ConversationStatus } from "#/types/conversation-status";
|
|||||||
import { StatusMessage } from "#/types/message";
|
import { StatusMessage } from "#/types/message";
|
||||||
import { RuntimeStatus } from "#/types/runtime-status";
|
import { RuntimeStatus } from "#/types/runtime-status";
|
||||||
import { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types";
|
import { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types";
|
||||||
|
import { isTaskPolling } from "./utils";
|
||||||
|
|
||||||
export enum IndicatorColor {
|
export enum IndicatorColor {
|
||||||
BLUE = "bg-blue-500",
|
BLUE = "bg-blue-500",
|
||||||
@ -105,10 +106,11 @@ export function getStatusCode(
|
|||||||
runtimeStatus: RuntimeStatus | null,
|
runtimeStatus: RuntimeStatus | null,
|
||||||
agentState: AgentState | null,
|
agentState: AgentState | null,
|
||||||
taskStatus?: V1AppConversationStartTaskStatus | null,
|
taskStatus?: V1AppConversationStartTaskStatus | null,
|
||||||
|
subConversationTaskStatus?: V1AppConversationStartTaskStatus | null,
|
||||||
) {
|
) {
|
||||||
// PRIORITY 1: Handle task error state (when start-tasks API returns ERROR)
|
// 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
|
// 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;
|
return I18nKey.AGENT_STATUS$ERROR_OCCURRED;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,7 +149,10 @@ export function getStatusCode(
|
|||||||
if (webSocketStatus === "DISCONNECTED") {
|
if (webSocketStatus === "DISCONNECTED") {
|
||||||
return I18nKey.CHAT_INTERFACE$DISCONNECTED;
|
return I18nKey.CHAT_INTERFACE$DISCONNECTED;
|
||||||
}
|
}
|
||||||
if (webSocketStatus === "CONNECTING") {
|
if (
|
||||||
|
webSocketStatus === "CONNECTING" ||
|
||||||
|
isTaskPolling(subConversationTaskStatus)
|
||||||
|
) {
|
||||||
return I18nKey.CHAT_INTERFACE$CONNECTING;
|
return I18nKey.CHAT_INTERFACE$CONNECTING;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -611,6 +611,22 @@ export const buildSessionHeaders = (
|
|||||||
return headers;
|
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
|
* Get the appropriate color based on agent status
|
||||||
* @param options Configuration object for status color calculation
|
* @param options Configuration object for status color calculation
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user