diff --git a/frontend/src/api/conversation-service/v1-conversation-service.api.ts b/frontend/src/api/conversation-service/v1-conversation-service.api.ts index e638b14ba5..4ec039ee2d 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.api.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.api.ts @@ -3,6 +3,7 @@ import { openHands } from "../open-hands-axios"; import { ConversationTrigger, GetVSCodeUrlResponse } from "../open-hands.types"; import { Provider } from "#/types/settings"; import { buildHttpBaseUrl } from "#/utils/websocket-url"; +import { buildSessionHeaders } from "#/utils/utils"; import type { V1SendMessageRequest, V1SendMessageResponse, @@ -13,21 +14,6 @@ import type { } from "./v1-conversation-service.types"; class V1ConversationService { - /** - * Build headers for V1 API requests that require session authentication - * @param sessionApiKey Session API key for authentication - * @returns Headers object with X-Session-API-Key if provided - */ - private static buildSessionHeaders( - sessionApiKey?: string | null, - ): Record { - const headers: Record = {}; - if (sessionApiKey) { - headers["X-Session-API-Key"] = sessionApiKey; - } - return headers; - } - /** * Build the full URL for V1 runtime-specific endpoints * @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...") @@ -160,7 +146,7 @@ class V1ConversationService { sessionApiKey?: string | null, ): Promise { const url = this.buildRuntimeUrl(conversationUrl, "/api/vscode/url"); - const headers = this.buildSessionHeaders(sessionApiKey); + const headers = buildSessionHeaders(sessionApiKey); // V1 API returns {url: '...'} instead of {vscode_url: '...'} // Map it to match the expected interface @@ -188,7 +174,7 @@ class V1ConversationService { conversationUrl, `/api/conversations/${conversationId}/pause`, ); - const headers = this.buildSessionHeaders(sessionApiKey); + const headers = buildSessionHeaders(sessionApiKey); const { data } = await axios.post<{ success: boolean }>( url, @@ -216,7 +202,7 @@ class V1ConversationService { conversationUrl, `/api/conversations/${conversationId}/run`, ); - const headers = this.buildSessionHeaders(sessionApiKey); + const headers = buildSessionHeaders(sessionApiKey); const { data } = await axios.post<{ success: boolean }>( url, @@ -305,7 +291,7 @@ class V1ConversationService { conversationUrl, `/api/file/upload/${encodedPath}`, ); - const headers = this.buildSessionHeaders(sessionApiKey); + const headers = buildSessionHeaders(sessionApiKey); // Create FormData with the file const formData = new FormData(); diff --git a/frontend/src/api/event-service/event-service.api.ts b/frontend/src/api/event-service/event-service.api.ts new file mode 100644 index 0000000000..90a1d4e64e --- /dev/null +++ b/frontend/src/api/event-service/event-service.api.ts @@ -0,0 +1,41 @@ +import axios from "axios"; +import { buildHttpBaseUrl } from "#/utils/websocket-url"; +import { buildSessionHeaders } from "#/utils/utils"; +import type { + ConfirmationResponseRequest, + ConfirmationResponseResponse, +} from "./event-service.types"; + +class EventService { + /** + * Respond to a confirmation request in a V1 conversation + * @param conversationId The conversation ID + * @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...") + * @param request The confirmation response request + * @param sessionApiKey Session API key for authentication (required for V1) + * @returns The confirmation response + */ + static async respondToConfirmation( + conversationId: string, + conversationUrl: string, + request: ConfirmationResponseRequest, + sessionApiKey?: string | null, + ): Promise { + // Build the runtime URL using the conversation URL + const runtimeUrl = buildHttpBaseUrl(conversationUrl); + + // Build session headers for authentication + const headers = buildSessionHeaders(sessionApiKey); + + // Make the API call to the runtime endpoint + const { data } = await axios.post( + `${runtimeUrl}/api/conversations/${conversationId}/events/respond_to_confirmation`, + request, + { headers }, + ); + + return data; + } +} + +export default EventService; diff --git a/frontend/src/api/event-service/event-service.types.ts b/frontend/src/api/event-service/event-service.types.ts new file mode 100644 index 0000000000..84c447f3e7 --- /dev/null +++ b/frontend/src/api/event-service/event-service.types.ts @@ -0,0 +1,8 @@ +export interface ConfirmationResponseRequest { + accept: boolean; + reason?: string; +} + +export interface ConfirmationResponseResponse { + success: boolean; +} diff --git a/frontend/src/components/features/chat/chat-interface.tsx b/frontend/src/components/features/chat/chat-interface.tsx index a6dacc9cda..800bb37762 100644 --- a/frontend/src/components/features/chat/chat-interface.tsx +++ b/frontend/src/components/features/chat/chat-interface.tsx @@ -237,14 +237,7 @@ export function ChatInterface() { /> )} - {v1UserEventsExist && ( - - )} + {v1UserEventsExist && }
diff --git a/frontend/src/components/shared/buttons/v1-confirmation-buttons.tsx b/frontend/src/components/shared/buttons/v1-confirmation-buttons.tsx new file mode 100644 index 0000000000..bba1bad4f3 --- /dev/null +++ b/frontend/src/components/shared/buttons/v1-confirmation-buttons.tsx @@ -0,0 +1,141 @@ +import { useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { AgentState } from "#/types/agent-state"; +import { ActionTooltip } from "../action-tooltip"; +import { RiskAlert } from "#/components/shared/risk-alert"; +import WarningIcon from "#/icons/u-warning.svg?react"; +import { useEventMessageStore } from "#/stores/event-message-store"; +import { useEventStore } from "#/stores/use-event-store"; +import { isV1Event, isActionEvent } from "#/types/v1/type-guards"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useAgentState } from "#/hooks/use-agent-state"; +import { useRespondToConfirmation } from "#/hooks/mutation/use-respond-to-confirmation"; +import { SecurityRisk } from "#/types/v1/core/base/common"; + +export function V1ConfirmationButtons() { + const v1SubmittedEventIds = useEventMessageStore( + (state) => state.v1SubmittedEventIds, + ); + const addV1SubmittedEventId = useEventMessageStore( + (state) => state.addV1SubmittedEventId, + ); + + const { t } = useTranslation(); + const { data: conversation } = useActiveConversation(); + const { curAgentState } = useAgentState(); + const { mutate: respondToConfirmation } = useRespondToConfirmation(); + const events = useEventStore((state) => state.events); + + // Find the most recent V1 action awaiting confirmation + const awaitingAction = events + .filter(isV1Event) + .slice() + .reverse() + .find((ev) => { + if (ev.source !== "agent") return false; + // For V1, we check if the agent state is waiting for confirmation + return curAgentState === AgentState.AWAITING_USER_CONFIRMATION; + }); + + const handleConfirmation = useCallback( + (accept: boolean) => { + if (!awaitingAction || !conversation) { + return; + } + + // Mark event as submitted to prevent duplicate submissions + addV1SubmittedEventId(awaitingAction.id); + + // Call the V1 API endpoint + respondToConfirmation({ + conversationId: conversation.conversation_id, + conversationUrl: conversation.url || "", + sessionApiKey: conversation.session_api_key, + accept, + }); + }, + [ + awaitingAction, + conversation, + addV1SubmittedEventId, + respondToConfirmation, + ], + ); + + // Handle keyboard shortcuts + useEffect(() => { + if (!awaitingAction) { + return undefined; + } + + const handleCancelShortcut = (event: KeyboardEvent) => { + if (event.shiftKey && event.metaKey && event.key === "Backspace") { + event.preventDefault(); + handleConfirmation(false); + } + }; + + const handleContinueShortcut = (event: KeyboardEvent) => { + if (event.metaKey && event.key === "Enter") { + event.preventDefault(); + handleConfirmation(true); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + // Cancel: Shift+Cmd+Backspace (⇧⌘⌫) + handleCancelShortcut(event); + // Continue: Cmd+Enter (⌘↩) + handleContinueShortcut(event); + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => document.removeEventListener("keydown", handleKeyDown); + }, [awaitingAction, handleConfirmation]); + + // Only show if agent is waiting for confirmation and we haven't already submitted + if ( + curAgentState !== AgentState.AWAITING_USER_CONFIRMATION || + !awaitingAction || + v1SubmittedEventIds.includes(awaitingAction.id) + ) { + return null; + } + + // Get security risk from the action (only ActionEvent has security_risk) + const risk = isActionEvent(awaitingAction) + ? awaitingAction.security_risk + : SecurityRisk.UNKNOWN; + + const isHighRisk = risk === SecurityRisk.HIGH; + + return ( +
+ {isHighRisk && ( + } + severity="high" + title={t(I18nKey.COMMON$HIGH_RISK)} + /> + )} +
+

+ {t(I18nKey.CHAT_INTERFACE$USER_ASK_CONFIRMATION)} +

+
+ handleConfirmation(false)} + /> + handleConfirmation(true)} + /> +
+
+
+ ); +} diff --git a/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx b/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx index c2ac1d9a73..ecb33e7c11 100644 --- a/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx +++ b/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx @@ -1,19 +1,18 @@ -import React from "react"; import { OpenHandsEvent } from "#/types/v1/core"; import { GenericEventMessage } from "../../../features/chat/generic-event-message"; import { getEventContent } from "../event-content-helpers/get-event-content"; import { getObservationResult } from "../event-content-helpers/get-observation-result"; import { isObservationEvent } from "#/types/v1/type-guards"; -import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons"; +import { V1ConfirmationButtons } from "#/components/shared/buttons/v1-confirmation-buttons"; interface GenericEventMessageWrapperProps { event: OpenHandsEvent; - shouldShowConfirmationButtons: boolean; + isLastMessage: boolean; } export function GenericEventMessageWrapper({ event, - shouldShowConfirmationButtons, + isLastMessage, }: GenericEventMessageWrapperProps) { const { title, details } = getEventContent(event); @@ -27,7 +26,7 @@ export function GenericEventMessageWrapper({ } initiallyExpanded={false} /> - {shouldShowConfirmationButtons && } + {isLastMessage && }
); } diff --git a/frontend/src/components/v1/chat/event-message-components/user-assistant-event-message.tsx b/frontend/src/components/v1/chat/event-message-components/user-assistant-event-message.tsx index 260f9688ef..6455dadbe3 100644 --- a/frontend/src/components/v1/chat/event-message-components/user-assistant-event-message.tsx +++ b/frontend/src/components/v1/chat/event-message-components/user-assistant-event-message.tsx @@ -4,7 +4,7 @@ import { ChatMessage } from "../../../features/chat/chat-message"; import { ImageCarousel } from "../../../features/images/image-carousel"; // TODO: Implement file_urls support for V1 messages // import { FileList } from "../../../features/files/file-list"; -import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons"; +import { V1ConfirmationButtons } from "#/components/shared/buttons/v1-confirmation-buttons"; import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper"; // TODO: Implement V1 LikertScaleWrapper when API supports V1 event IDs // import { LikertScaleWrapper } from "../../../features/chat/event-message-components/likert-scale-wrapper"; @@ -13,7 +13,6 @@ import { MicroagentStatus } from "#/types/microagent-status"; interface UserAssistantEventMessageProps { event: MessageEvent; - shouldShowConfirmationButtons: boolean; microagentStatus?: MicroagentStatus | null; microagentConversationId?: string; microagentPRUrl?: string; @@ -22,15 +21,16 @@ interface UserAssistantEventMessageProps { onClick: () => void; tooltip?: string; }>; + isLastMessage: boolean; } export function UserAssistantEventMessage({ event, - shouldShowConfirmationButtons, microagentStatus, microagentConversationId, microagentPRUrl, actions, + isLastMessage, }: UserAssistantEventMessageProps) { const message = parseMessageFromEvent(event); @@ -51,7 +51,7 @@ export function UserAssistantEventMessage({ )} {/* TODO: Handle file_urls if V1 messages support them */} - {shouldShowConfirmationButtons && } + {isLastMessage && } ); } // Generic fallback for all other events (including observation events) return ( - + ); } diff --git a/frontend/src/components/v1/chat/messages.tsx b/frontend/src/components/v1/chat/messages.tsx index b6b7a1ca1d..d6cc018090 100644 --- a/frontend/src/components/v1/chat/messages.tsx +++ b/frontend/src/components/v1/chat/messages.tsx @@ -10,11 +10,10 @@ import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message- interface MessagesProps { messages: OpenHandsEvent[]; - isAwaitingUserConfirmation: boolean; } export const Messages: React.FC = React.memo( - ({ messages, isAwaitingUserConfirmation }) => { + ({ messages }) => { const { getOptimisticUserMessage } = useOptimisticUserMessageStore(); const optimisticUserMessage = getOptimisticUserMessage(); @@ -43,7 +42,6 @@ export const Messages: React.FC = React.memo( key={message.id} event={message} hasObservationPair={actionHasObservationPair(message)} - isAwaitingUserConfirmation={isAwaitingUserConfirmation} isLastMessage={messages.length - 1 === index} isInLast10Actions={messages.length - 1 - index < 10} // Microagent props - not implemented yet for V1 diff --git a/frontend/src/hooks/mutation/use-respond-to-confirmation.ts b/frontend/src/hooks/mutation/use-respond-to-confirmation.ts new file mode 100644 index 0000000000..26c267ce99 --- /dev/null +++ b/frontend/src/hooks/mutation/use-respond-to-confirmation.ts @@ -0,0 +1,32 @@ +import { useMutation } from "@tanstack/react-query"; +import EventService from "#/api/event-service/event-service.api"; +import type { ConfirmationResponseRequest } from "#/api/event-service/event-service.types"; + +interface UseRespondToConfirmationVariables { + conversationId: string; + conversationUrl: string; + sessionApiKey?: string | null; + accept: boolean; +} + +export const useRespondToConfirmation = () => + useMutation({ + mutationKey: ["respond-to-confirmation"], + mutationFn: async ({ + conversationId, + conversationUrl, + sessionApiKey, + accept, + }: UseRespondToConfirmationVariables) => { + const request: ConfirmationResponseRequest = { + accept, + }; + + return EventService.respondToConfirmation( + conversationId, + conversationUrl, + request, + sessionApiKey, + ); + }, + }); diff --git a/frontend/src/stores/event-message-store.ts b/frontend/src/stores/event-message-store.ts index 09315b7e5e..08ba465bac 100644 --- a/frontend/src/stores/event-message-store.ts +++ b/frontend/src/stores/event-message-store.ts @@ -2,15 +2,19 @@ import { create } from "zustand"; interface EventMessageState { submittedEventIds: number[]; // Avoid the flashing issue of the confirmation buttons + v1SubmittedEventIds: string[]; // V1 event IDs (V1 uses string IDs) } interface EventMessageStore extends EventMessageState { addSubmittedEventId: (id: number) => void; removeSubmittedEventId: (id: number) => void; + addV1SubmittedEventId: (id: string) => void; + removeV1SubmittedEventId: (id: string) => void; } export const useEventMessageStore = create((set) => ({ submittedEventIds: [], + v1SubmittedEventIds: [], addSubmittedEventId: (id: number) => set((state) => ({ submittedEventIds: [...state.submittedEventIds, id], @@ -21,4 +25,14 @@ export const useEventMessageStore = create((set) => ({ (eventId) => eventId !== id, ), })), + addV1SubmittedEventId: (id: string) => + set((state) => ({ + v1SubmittedEventIds: [...state.v1SubmittedEventIds, id], + })), + removeV1SubmittedEventId: (id: string) => + set((state) => ({ + v1SubmittedEventIds: state.v1SubmittedEventIds.filter( + (eventId) => eventId !== id, + ), + })), })); diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index baf6b85d1a..8603804ecc 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -594,3 +594,18 @@ export const hasOpenHandsSuffix = ( } return repo.full_name.endsWith("/.openhands"); }; + +/** + * Build headers for V1 API requests that require session authentication + * @param sessionApiKey Session API key for authentication + * @returns Headers object with X-Session-API-Key if provided + */ +export const buildSessionHeaders = ( + sessionApiKey?: string | null, +): Record => { + const headers: Record = {}; + if (sessionApiKey) { + headers["X-Session-API-Key"] = sessionApiKey; + } + return headers; +};