diff --git a/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx b/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx index bf5ba79e79..4cbb82820b 100644 --- a/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx +++ b/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx @@ -295,4 +295,238 @@ describe("ConversationPanel", () => { const newCards = await screen.findAllByTestId("conversation-card"); expect(newCards).toHaveLength(3); }); + + it("should cancel stopping a conversation", async () => { + const user = userEvent.setup(); + + // Create mock data with a RUNNING conversation + const mockRunningConversations: Conversation[] = [ + { + conversation_id: "1", + title: "Running Conversation", + selected_repository: null, + git_provider: null, + selected_branch: null, + last_updated_at: "2021-10-01T12:00:00Z", + created_at: "2021-10-01T12:00:00Z", + status: "RUNNING" as const, + runtime_status: null, + url: null, + session_api_key: null, + }, + { + conversation_id: "2", + title: "Starting Conversation", + selected_repository: null, + git_provider: null, + selected_branch: null, + last_updated_at: "2021-10-02T12:00:00Z", + created_at: "2021-10-02T12:00:00Z", + status: "STARTING" as const, + runtime_status: null, + url: null, + session_api_key: null, + }, + { + conversation_id: "3", + title: "Stopped Conversation", + selected_repository: null, + git_provider: null, + selected_branch: null, + last_updated_at: "2021-10-03T12:00:00Z", + created_at: "2021-10-03T12:00:00Z", + status: "STOPPED" as const, + runtime_status: null, + url: null, + session_api_key: null, + }, + ]; + + const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations"); + getUserConversationsSpy.mockResolvedValue(mockRunningConversations); + + renderConversationPanel(); + + const cards = await screen.findAllByTestId("conversation-card"); + expect(cards).toHaveLength(3); + + // Click ellipsis on the first card (RUNNING status) + const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button"); + await user.click(ellipsisButton); + + // Stop button should be available for RUNNING conversation + const stopButton = screen.getByTestId("stop-button"); + expect(stopButton).toBeInTheDocument(); + + // Click the stop button + await user.click(stopButton); + + // Cancel the stopping action + const cancelButton = screen.getByRole("button", { name: /cancel/i }); + await user.click(cancelButton); + + expect( + screen.queryByRole("button", { name: /cancel/i }), + ).not.toBeInTheDocument(); + + // Ensure the conversation status hasn't changed + const updatedCards = await screen.findAllByTestId("conversation-card"); + expect(updatedCards).toHaveLength(3); + }); + + it("should stop a conversation", async () => { + const user = userEvent.setup(); + + const mockData: Conversation[] = [ + { + conversation_id: "1", + title: "Running Conversation", + selected_repository: null, + git_provider: null, + selected_branch: null, + last_updated_at: "2021-10-01T12:00:00Z", + created_at: "2021-10-01T12:00:00Z", + status: "RUNNING" as const, + runtime_status: null, + url: null, + session_api_key: null, + }, + { + conversation_id: "2", + title: "Starting Conversation", + selected_repository: null, + git_provider: null, + selected_branch: null, + last_updated_at: "2021-10-02T12:00:00Z", + created_at: "2021-10-02T12:00:00Z", + status: "STARTING" as const, + runtime_status: null, + url: null, + session_api_key: null, + }, + ]; + + const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations"); + getUserConversationsSpy.mockImplementation(async () => mockData); + + const stopConversationSpy = vi.spyOn(OpenHands, "stopConversation"); + stopConversationSpy.mockImplementation(async (id: string) => { + const conversation = mockData.find((conv) => conv.conversation_id === id); + if (conversation) { + conversation.status = "STOPPED"; + return conversation; + } + return null; + }); + + renderConversationPanel(); + + const cards = await screen.findAllByTestId("conversation-card"); + expect(cards).toHaveLength(2); + + // Click ellipsis on the first card (RUNNING status) + const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button"); + await user.click(ellipsisButton); + + const stopButton = screen.getByTestId("stop-button"); + + // Click the stop button + await user.click(stopButton); + + // Confirm the stopping action + const confirmButton = screen.getByRole("button", { name: /confirm/i }); + await user.click(confirmButton); + + expect( + screen.queryByRole("button", { name: /confirm/i }), + ).not.toBeInTheDocument(); + + // Verify the API was called + expect(stopConversationSpy).toHaveBeenCalledWith("1"); + expect(stopConversationSpy).toHaveBeenCalledTimes(1); + }); + + it("should only show stop button for STARTING or RUNNING conversations", async () => { + const user = userEvent.setup(); + + const mockMixedStatusConversations: Conversation[] = [ + { + conversation_id: "1", + title: "Running Conversation", + selected_repository: null, + git_provider: null, + selected_branch: null, + last_updated_at: "2021-10-01T12:00:00Z", + created_at: "2021-10-01T12:00:00Z", + status: "RUNNING" as const, + runtime_status: null, + url: null, + session_api_key: null, + }, + { + conversation_id: "2", + title: "Starting Conversation", + selected_repository: null, + git_provider: null, + selected_branch: null, + last_updated_at: "2021-10-02T12:00:00Z", + created_at: "2021-10-02T12:00:00Z", + status: "STARTING" as const, + runtime_status: null, + url: null, + session_api_key: null, + }, + { + conversation_id: "3", + title: "Stopped Conversation", + selected_repository: null, + git_provider: null, + selected_branch: null, + last_updated_at: "2021-10-03T12:00:00Z", + created_at: "2021-10-03T12:00:00Z", + status: "STOPPED" as const, + runtime_status: null, + url: null, + session_api_key: null, + }, + ]; + + const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations"); + getUserConversationsSpy.mockResolvedValue(mockMixedStatusConversations); + + renderConversationPanel(); + + const cards = await screen.findAllByTestId("conversation-card"); + expect(cards).toHaveLength(3); + + // Test RUNNING conversation - should show stop button + const runningEllipsisButton = within(cards[0]).getByTestId( + "ellipsis-button", + ); + await user.click(runningEllipsisButton); + + expect(screen.getByTestId("stop-button")).toBeInTheDocument(); + + // Click outside to close the menu + await user.click(document.body); + + // Test STARTING conversation - should show stop button + const startingEllipsisButton = within(cards[1]).getByTestId( + "ellipsis-button", + ); + await user.click(startingEllipsisButton); + + expect(screen.getByTestId("stop-button")).toBeInTheDocument(); + + // Click outside to close the menu + await user.click(document.body); + + // Test STOPPED conversation - should NOT show stop button + const stoppedEllipsisButton = within(cards[2]).getByTestId( + "ellipsis-button", + ); + await user.click(stoppedEllipsisButton); + + expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/features/conversation-panel/confirm-stop-modal.tsx b/frontend/src/components/features/conversation-panel/confirm-stop-modal.tsx new file mode 100644 index 0000000000..162d43a9eb --- /dev/null +++ b/frontend/src/components/features/conversation-panel/confirm-stop-modal.tsx @@ -0,0 +1,57 @@ +import { useTranslation } from "react-i18next"; +import { + BaseModalDescription, + BaseModalTitle, +} from "#/components/shared/modals/confirmation-modals/base-modal"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalBody } from "#/components/shared/modals/modal-body"; +import { BrandButton } from "../settings/brand-button"; +import { I18nKey } from "#/i18n/declaration"; + +interface ConfirmStopModalProps { + onConfirm: () => void; + onCancel: () => void; +} + +export function ConfirmStopModal({ + onConfirm, + onCancel, +}: ConfirmStopModalProps) { + const { t } = useTranslation(); + + return ( + + +
+ + +
+
event.stopPropagation()} + > + + {t(I18nKey.ACTION$CONFIRM)} + + + {t(I18nKey.BUTTON$CANCEL)} + +
+
+
+ ); +} diff --git a/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx b/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx index 13cebe1122..a24072e9ad 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx @@ -8,6 +8,7 @@ import { I18nKey } from "#/i18n/declaration"; interface ConversationCardContextMenuProps { onClose: () => void; onDelete?: (event: React.MouseEvent) => void; + onStop?: (event: React.MouseEvent) => void; onEdit?: (event: React.MouseEvent) => void; onDisplayCost?: (event: React.MouseEvent) => void; onShowAgentTools?: (event: React.MouseEvent) => void; @@ -19,6 +20,7 @@ interface ConversationCardContextMenuProps { export function ConversationCardContextMenu({ onClose, onDelete, + onStop, onEdit, onDisplayCost, onShowAgentTools, @@ -44,6 +46,11 @@ export function ConversationCardContextMenu({ Delete )} + {onStop && ( + + Stop + + )} {onEdit && ( Edit Title diff --git a/frontend/src/components/features/conversation-panel/conversation-card.tsx b/frontend/src/components/features/conversation-panel/conversation-card.tsx index 38f790f548..19dde0b97a 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card.tsx @@ -23,6 +23,7 @@ import { ConversationStatus } from "#/types/conversation-status"; interface ConversationCardProps { onClick?: () => void; onDelete?: () => void; + onStop?: () => void; onChangeTitle?: (title: string) => void; showOptions?: boolean; isActive?: boolean; @@ -40,6 +41,7 @@ const MAX_TIME_BETWEEN_CREATION_AND_UPDATE = 1000 * 60 * 30; // 30 minutes export function ConversationCard({ onClick, onDelete, + onStop, onChangeTitle, showOptions, isActive, @@ -101,6 +103,13 @@ export function ConversationCard({ setContextMenuVisible(false); }; + const handleStop = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onStop?.(); + setContextMenuVisible(false); + }; + const handleEdit = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); @@ -224,6 +233,11 @@ export function ConversationCard({ setContextMenuVisible(false)} onDelete={onDelete && handleDelete} + onStop={ + conversationStatus !== "STOPPED" + ? onStop && handleStop + : undefined + } onEdit={onChangeTitle && handleEdit} onDownloadViaVSCode={ conversationId && showOptions diff --git a/frontend/src/components/features/conversation-panel/conversation-panel.tsx b/frontend/src/components/features/conversation-panel/conversation-panel.tsx index decf9e6cd3..34b8ce9269 100644 --- a/frontend/src/components/features/conversation-panel/conversation-panel.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-panel.tsx @@ -5,7 +5,9 @@ import { I18nKey } from "#/i18n/declaration"; import { ConversationCard } from "./conversation-card"; import { useUserConversations } from "#/hooks/query/use-user-conversations"; import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation"; +import { useStopConversation } from "#/hooks/mutation/use-stop-conversation"; import { ConfirmDeleteModal } from "./confirm-delete-modal"; +import { ConfirmStopModal } from "./confirm-stop-modal"; import { LoadingSpinner } from "#/components/shared/loading-spinner"; import { ExitConversationModal } from "./exit-conversation-modal"; import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; @@ -22,6 +24,8 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) { const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] = React.useState(false); + const [confirmStopModalVisible, setConfirmStopModalVisible] = + React.useState(false); const [ confirmExitConversationModalVisible, setConfirmExitConversationModalVisible, @@ -33,12 +37,18 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) { const { data: conversations, isFetching, error } = useUserConversations(); const { mutate: deleteConversation } = useDeleteConversation(); + const { mutate: stopConversation } = useStopConversation(); const handleDeleteProject = (conversationId: string) => { setConfirmDeleteModalVisible(true); setSelectedConversationId(conversationId); }; + const handleStopConversation = (conversationId: string) => { + setConfirmStopModalVisible(true); + setSelectedConversationId(conversationId); + }; + const handleConfirmDelete = () => { if (selectedConversationId) { deleteConversation( @@ -54,6 +64,21 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) { } }; + const handleConfirmStop = () => { + if (selectedConversationId) { + stopConversation( + { conversationId: selectedConversationId }, + { + onSuccess: () => { + if (selectedConversationId === currentConversationId) { + navigate("/"); + } + }, + }, + ); + } + }; + return (
handleDeleteProject(project.conversation_id)} + onStop={() => handleStopConversation(project.conversation_id)} title={project.title} selectedRepository={project.selected_repository} lastUpdatedAt={project.last_updated_at} @@ -108,6 +134,16 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) { /> )} + {confirmStopModalVisible && ( + { + handleConfirmStop(); + setConfirmStopModalVisible(false); + }} + onCancel={() => setConfirmStopModalVisible(false)} + /> + )} + {confirmExitConversationModalVisible && ( { diff --git a/frontend/src/hooks/mutation/use-stop-conversation.ts b/frontend/src/hooks/mutation/use-stop-conversation.ts new file mode 100644 index 0000000000..ae7298d1b1 --- /dev/null +++ b/frontend/src/hooks/mutation/use-stop-conversation.ts @@ -0,0 +1,31 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import OpenHands from "#/api/open-hands"; + +export const useStopConversation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (variables: { conversationId: string }) => + OpenHands.stopConversation(variables.conversationId), + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: ["user", "conversations"] }); + const previousConversations = queryClient.getQueryData([ + "user", + "conversations", + ]); + + return { previousConversations }; + }, + onError: (_, __, context) => { + if (context?.previousConversations) { + queryClient.setQueryData( + ["user", "conversations"], + context.previousConversations, + ); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["user", "conversations"] }); + }, + }); +}; diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 8aba24b6f3..4134e56080 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -296,6 +296,8 @@ export enum I18nKey { LANDING$UPLOAD_TRAJECTORY = "LANDING$UPLOAD_TRAJECTORY", LANDING$RECENT_CONVERSATION = "LANDING$RECENT_CONVERSATION", CONVERSATION$CONFIRM_DELETE = "CONVERSATION$CONFIRM_DELETE", + CONVERSATION$CONFIRM_STOP = "CONVERSATION$CONFIRM_STOP", + CONVERSATION$STOP_WARNING = "CONVERSATION$STOP_WARNING", CONVERSATION$METRICS_INFO = "CONVERSATION$METRICS_INFO", CONVERSATION$CREATED = "CONVERSATION$CREATED", CONVERSATION$AGO = "CONVERSATION$AGO", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 0d159da214..7f977fc913 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -4735,6 +4735,38 @@ "de": "Löschen bestätigen", "uk": "Підтвердити видалення" }, + "CONVERSATION$CONFIRM_STOP": { + "en": "Confirm Stop", + "ja": "停止の確認", + "zh-CN": "确认停止", + "zh-TW": "確認停止", + "ko-KR": "중지 확인", + "no": "Bekreft stopp", + "it": "Conferma arresto", + "pt": "Confirmar parada", + "es": "Confirmar detención", + "ar": "تأكيد الإيقاف", + "fr": "Confirmer l'arrêt", + "tr": "Durdurmayı Onayla", + "de": "Stopp bestätigen", + "uk": "Підтвердити зупинку" + }, + "CONVERSATION$STOP_WARNING": { + "en": "Are you sure you want to stop this conversation?", + "ja": "この会話を停止してもよろしいですか?", + "zh-CN": "您确定要停止此对话吗?", + "zh-TW": "您確定要停止此對話嗎?", + "ko-KR": "이 대화를 중지하시겠습니까?", + "no": "Er du sikker på at du vil stoppe denne samtalen?", + "it": "Sei sicuro di voler fermare questa conversazione?", + "pt": "Tem certeza de que deseja parar esta conversa?", + "es": "¿Está seguro de que desea detener esta conversación?", + "ar": "هل أنت متأكد أنك تريد إيقاف هذه المحادثة؟", + "fr": "Êtes-vous sûr de vouloir arrêter cette conversation ?", + "tr": "Bu konuşmayı durdurmak istediğinizden emin misiniz?", + "de": "Sind Sie sicher, dass Sie dieses Gespräch stoppen möchten?", + "uk": "Ви впевнені, що хочете зупинити цю розмову?" + }, "CONVERSATION$METRICS_INFO": { "en": "Conversation Metrics", "ja": "会話メトリクス",