From 100bcf9fce3f72ccb400beb7ddaa4faedaff62a9 Mon Sep 17 00:00:00 2001 From: hieptl Date: Tue, 14 Jul 2026 20:00:10 +0700 Subject: [PATCH] feat: show a modal when an OpenHands Cloud conversation limit is reached --- .../features/home/home-chat-launcher.test.tsx | 39 ++++++++++ __tests__/query-client-config.test.ts | 38 ++++++++++ .../utils/concurrency-limit-error.test.ts | 72 +++++++++++++++++++ .../conversation/conversation-limit-modal.tsx | 44 ++++++++++++ .../features/home/home-chat-launcher.tsx | 3 + src/i18n/translation.json | 34 +++++++++ src/query-client-config.ts | 14 ++++ src/routes/root-layout.tsx | 20 ++++++ src/stores/conversation-limit-store.ts | 34 +++++++++ src/utils/concurrency-limit-error.ts | 37 ++++++++++ src/utils/constants.ts | 7 ++ 11 files changed, 342 insertions(+) create mode 100644 __tests__/utils/concurrency-limit-error.test.ts create mode 100644 src/components/features/conversation/conversation-limit-modal.tsx create mode 100644 src/stores/conversation-limit-store.ts create mode 100644 src/utils/concurrency-limit-error.ts diff --git a/__tests__/components/features/home/home-chat-launcher.test.tsx b/__tests__/components/features/home/home-chat-launcher.test.tsx index 2be585594..01d5ffba4 100644 --- a/__tests__/components/features/home/home-chat-launcher.test.tsx +++ b/__tests__/components/features/home/home-chat-launcher.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import toast from "react-hot-toast"; +import { AxiosError } from "axios"; import { HomeChatLauncher } from "#/components/features/home/home-chat-launcher"; import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; @@ -257,6 +258,26 @@ function makeConversationResponse( } as never; } +function makeLimitError(limit = 3): AxiosError { + return new AxiosError( + "Request failed with status code 429", + "ERR_BAD_REQUEST", + undefined, + undefined, + { + status: 429, + data: { + detail: { + error: "CONCURRENCY_LIMIT_REACHED", + message: `You have reached your limit of ${limit} concurrent conversations.`, + limit, + current: limit, + }, + }, + } as never, + ); +} + const localBackend = { backend: { id: "local-id", @@ -620,4 +641,22 @@ describe("HomeChatLauncher", () => { undefined, ); }); + + it("suppresses the generic error toast when creation hits the cloud conversation limit", async () => { + mockUseActiveBackend.mockReturnValue(cloudBackend); + const dismissSpy = vi.spyOn(toast, "dismiss"); + vi.spyOn( + AgentServerConversationService, + "createConversation", + ).mockRejectedValue(makeLimitError()); + + renderLauncher(); + const user = userEvent.setup(); + await user.click(screen.getByTestId("stub-chat-submit")); + + // onError dismisses the loading toast first; once that has run, the + // suppression branch (skip displayErrorToast) has executed too. + await waitFor(() => expect(dismissSpy).toHaveBeenCalled()); + expect(mockDisplayErrorToast).not.toHaveBeenCalled(); + }); }); diff --git a/__tests__/query-client-config.test.ts b/__tests__/query-client-config.test.ts index 743adce6e..6e21a4455 100644 --- a/__tests__/query-client-config.test.ts +++ b/__tests__/query-client-config.test.ts @@ -1,8 +1,10 @@ import { AxiosError } from "axios"; +import { MutationObserver } from "@tanstack/react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createAgentServerQueryClient } from "#/query-client-config"; import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"; import * as ToastHandlers from "#/utils/custom-toast-handlers"; +import { useConversationLimitStore } from "#/stores/conversation-limit-store"; afterEach(() => { window.localStorage.clear(); @@ -89,4 +91,40 @@ describe("createAgentServerQueryClient", () => { expect(toastSpy).not.toHaveBeenCalled(); }); + + it("opens the conversation-limit modal instead of toasting on a cloud limit error", async () => { + const toastSpy = vi.spyOn(ToastHandlers, "displayErrorToast"); + const client = createAgentServerQueryClient(); + const limitError = new AxiosError( + "Request failed with status code 429", + "ERR_BAD_REQUEST", + undefined, + undefined, + { + status: 429, + data: { + detail: { + error: "CONCURRENCY_LIMIT_REACHED", + message: + "You have reached your limit of 3 concurrent conversations.", + limit: 3, + current: 3, + }, + }, + } as never, + ); + + const observer = new MutationObserver(client, { + mutationFn: async () => { + throw limitError; + }, + }); + await observer.mutate().catch(() => {}); + + expect(useConversationLimitStore.getState()).toMatchObject({ + isOpen: true, + limit: 3, + }); + expect(toastSpy).not.toHaveBeenCalled(); + }); }); diff --git a/__tests__/utils/concurrency-limit-error.test.ts b/__tests__/utils/concurrency-limit-error.test.ts new file mode 100644 index 000000000..bf0cd2643 --- /dev/null +++ b/__tests__/utils/concurrency-limit-error.test.ts @@ -0,0 +1,72 @@ +import { AxiosError } from "axios"; +import { describe, expect, it } from "vitest"; +import { + isConcurrencyLimitError, + getConcurrencyLimit, +} from "#/utils/concurrency-limit-error"; +import { DEFAULT_CONCURRENT_SANDBOX_LIMIT } from "#/utils/constants"; + +function makeAxiosError(status: number, data: unknown): AxiosError { + return new AxiosError( + `Request failed with status code ${status}`, + "ERR_BAD_REQUEST", + undefined, + undefined, + { status, data } as never, + ); +} + +// limit deliberately differs from DEFAULT_CONCURRENT_SANDBOX_LIMIT so the +// "reads the detail" case can't pass by accidentally hitting the fallback. +const limitResponse = { + detail: { + error: "CONCURRENCY_LIMIT_REACHED", + message: "You have reached your limit of 5 concurrent conversations.", + limit: 5, + current: 5, + }, +}; + +describe("isConcurrencyLimitError", () => { + it("is true for a 429 whose detail.error is CONCURRENCY_LIMIT_REACHED", () => { + expect(isConcurrencyLimitError(makeAxiosError(429, limitResponse))).toBe( + true, + ); + }); + + it("is false for a 429 carrying a different detail.error", () => { + expect( + isConcurrencyLimitError( + makeAxiosError(429, { detail: { error: "RATE_LIMITED" } }), + ), + ).toBe(false); + }); + + it("is false for the limit error code on a non-429 status", () => { + expect(isConcurrencyLimitError(makeAxiosError(403, limitResponse))).toBe( + false, + ); + }); + + it("is false for a non-Axios error", () => { + expect(isConcurrencyLimitError(new Error("boom"))).toBe(false); + }); +}); + +describe("getConcurrencyLimit", () => { + it("returns the limit carried in the error detail", () => { + const error = makeAxiosError(429, limitResponse); + if (!isConcurrencyLimitError(error)) + throw new Error("expected limit error"); + expect(getConcurrencyLimit(error)).toBe(5); + }); + + it("falls back to the default when the detail omits a limit", () => { + const error = makeAxiosError(429, { + detail: { error: "CONCURRENCY_LIMIT_REACHED" }, + }); + if (!isConcurrencyLimitError(error)) + throw new Error("expected limit error"); + expect(getConcurrencyLimit(error)).toBe(DEFAULT_CONCURRENT_SANDBOX_LIMIT); + }); +}); diff --git a/src/components/features/conversation/conversation-limit-modal.tsx b/src/components/features/conversation/conversation-limit-modal.tsx new file mode 100644 index 000000000..d13fc01a1 --- /dev/null +++ b/src/components/features/conversation/conversation-limit-modal.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { modalTitleClassName } from "#/utils/modal-classes"; +import { DEFAULT_CONCURRENT_SANDBOX_LIMIT } from "#/utils/constants"; + +interface ConversationLimitModalProps { + onClose: () => void; + limit?: number; +} + +export function ConversationLimitModal({ + onClose, + limit = DEFAULT_CONCURRENT_SANDBOX_LIMIT, +}: ConversationLimitModalProps) { + const { t } = useTranslation("openhands"); + + return ( + +
+

+ {t(I18nKey.CONVERSATION_LIMIT$TITLE)} +

+

+ {t(I18nKey.CONVERSATION_LIMIT$DESCRIPTION, { limit })} +

+
+ + {t(I18nKey.BUTTON$CLOSE)} + +
+
+
+ ); +} diff --git a/src/components/features/home/home-chat-launcher.tsx b/src/components/features/home/home-chat-launcher.tsx index f2a72c16b..3198083e4 100644 --- a/src/components/features/home/home-chat-launcher.tsx +++ b/src/components/features/home/home-chat-launcher.tsx @@ -24,6 +24,7 @@ import { displayErrorToast, TOAST_OPTIONS, } from "#/utils/custom-toast-handlers"; +import { isConcurrencyLimitError } from "#/utils/concurrency-limit-error"; import { getWorkspacesUnsupportedMessage } from "#/utils/workspaces-compatibility"; import type { PluginSpec } from "#/api/conversation-service/agent-server-conversation-service.types"; import { PluginPickerModal } from "#/components/features/plugins/plugin-picker-modal"; @@ -203,6 +204,8 @@ export function HomeChatLauncher() { navigate(`/conversations/${targetConversationId}`); } catch (error) { toast.dismiss(toastId); + // The conversation-limit modal already explains this failure. + if (isConcurrencyLimitError(error)) return; displayErrorToast(error instanceof Error ? error.message : null); } })(); diff --git a/src/i18n/translation.json b/src/i18n/translation.json index 176aa8256..aee092640 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -1,4 +1,38 @@ { + "CONVERSATION_LIMIT$TITLE": { + "en": "Conversation limit reached", + "ja": "会話数の上限に達しました", + "zh-CN": "已达到对话数量上限", + "zh-TW": "已達到對話數量上限", + "ko-KR": "대화 한도에 도달했습니다", + "no": "Samtalegrense nådd", + "it": "Limite di conversazioni raggiunto", + "pt": "Limite de conversas atingido", + "es": "Límite de conversaciones alcanzado", + "ar": "تم الوصول إلى حد المحادثات", + "fr": "Limite de conversations atteinte", + "tr": "Konuşma sınırına ulaşıldı", + "de": "Konversationslimit erreicht", + "uk": "Досягнуто ліміту розмов", + "ca": "S'ha assolit el límit de converses" + }, + "CONVERSATION_LIMIT$DESCRIPTION": { + "en": "You've reached the maximum number of concurrent conversations ({{limit}}) for your OpenHands Cloud plan. To start a new one, stop the runtime of an existing conversation.", + "ja": "OpenHands Cloud プランで同時に実行できる会話の上限({{limit}})に達しました。新しい会話を開始するには、既存の会話のランタイムを停止してください。", + "zh-CN": "您已达到 OpenHands Cloud 套餐允许的并发对话数量上限({{limit}})。若要开始新的对话,请先停止某个现有对话的运行时。", + "zh-TW": "您已達到 OpenHands Cloud 方案允許的並行對話數量上限({{limit}})。若要開始新的對話,請先停止某個現有對話的執行階段。", + "ko-KR": "OpenHands Cloud 플랜에서 동시에 실행할 수 있는 대화 수({{limit}})의 한도에 도달했습니다. 새 대화를 시작하려면 기존 대화의 런타임을 중지하세요.", + "no": "Du har nådd det maksimale antallet samtidige samtaler ({{limit}}) for OpenHands Cloud-abonnementet ditt. For å starte en ny, stopp kjøretiden til en eksisterende samtale.", + "it": "Hai raggiunto il numero massimo di conversazioni simultanee ({{limit}}) consentito dal tuo piano OpenHands Cloud. Per avviarne una nuova, arresta il runtime di una conversazione esistente.", + "pt": "Você atingiu o número máximo de conversas simultâneas ({{limit}}) do seu plano OpenHands Cloud. Para iniciar uma nova, pare o runtime de uma conversa existente.", + "es": "Has alcanzado el número máximo de conversaciones simultáneas ({{limit}}) de tu plan de OpenHands Cloud. Para iniciar una nueva, detén el runtime de una conversación existente.", + "ar": "لقد وصلت إلى الحد الأقصى لعدد المحادثات المتزامنة ({{limit}}) في خطة OpenHands Cloud الخاصة بك. لبدء محادثة جديدة، أوقف وقت تشغيل إحدى المحادثات الحالية.", + "fr": "Vous avez atteint le nombre maximal de conversations simultanées ({{limit}}) de votre forfait OpenHands Cloud. Pour en démarrer une nouvelle, arrêtez le runtime d'une conversation existante.", + "tr": "OpenHands Cloud planınız için eşzamanlı konuşma sayısının üst sınırına ({{limit}}) ulaştınız. Yeni bir konuşma başlatmak için mevcut bir konuşmanın çalışma zamanını durdurun.", + "de": "Sie haben die maximale Anzahl gleichzeitiger Konversationen ({{limit}}) Ihres OpenHands Cloud-Tarifs erreicht. Um eine neue zu starten, stoppen Sie die Laufzeitumgebung einer bestehenden Konversation.", + "uk": "Ви досягли максимальної кількості одночасних розмов ({{limit}}) у вашому плані OpenHands Cloud. Щоб розпочати нову, зупиніть середовище виконання наявної розмови.", + "ca": "Heu assolit el nombre màxim de converses simultànies ({{limit}}) del vostre pla d'OpenHands Cloud. Per iniciar-ne una de nova, atureu el temps d'execució d'una conversa existent." + }, "COMMON$PATTERN": { "ar": "النمط:", "ca": "Patró:", diff --git a/src/query-client-config.ts b/src/query-client-config.ts index c4d28ea88..c59515287 100644 --- a/src/query-client-config.ts +++ b/src/query-client-config.ts @@ -6,6 +6,11 @@ import { retrieveAxiosErrorMessage } from "./utils/retrieve-axios-error-message" import { displayErrorToast } from "./utils/custom-toast-handlers"; import { getActiveBackend } from "#/api/backend-registry/active-store"; import { recordBackendSuccess } from "#/api/backend-registry/health-store"; +import { + isConcurrencyLimitError, + getConcurrencyLimit, +} from "#/utils/concurrency-limit-error"; +import { useConversationLimitStore } from "#/stores/conversation-limit-store"; const handle401Error = (error: AxiosError, client: QueryClient) => { if (error?.response?.status === 401 || error?.status === 401) { @@ -66,6 +71,15 @@ export const createAgentServerQueryClient = () => { onError: (error, _, __, mutation) => { handle401Error(error, client); + // A cloud backend rejecting creation for too many concurrent + // conversations gets a dedicated modal instead of a generic toast. + if (isConcurrencyLimitError(error)) { + useConversationLimitStore + .getState() + .showLimitModal(getConcurrencyLimit(error)); + return; + } + const disableToast = mutation?.meta?.disableToast ?? mutation?.options.meta?.disableToast; diff --git a/src/routes/root-layout.tsx b/src/routes/root-layout.tsx index 86003e2df..1f30ac008 100644 --- a/src/routes/root-layout.tsx +++ b/src/routes/root-layout.tsx @@ -22,6 +22,7 @@ import { useAppTitle } from "#/hooks/use-app-title"; import { ReactRouterNavigationProvider } from "./react-router-navigation-provider"; import { OnboardingHost } from "#/components/features/onboarding"; import { isOnboardingPreviewActive } from "#/components/features/onboarding/onboarding-preview"; +import { useConversationLimitStore } from "#/stores/conversation-limit-store"; const EnvironmentSwitchOverlay = React.lazy( () => import("#/components/features/backends/environment-switch-overlay"), @@ -36,6 +37,11 @@ const CommandMenu = React.lazy(() => default: m.CommandMenu, })), ); +const ConversationLimitModal = React.lazy(() => + import("#/components/features/conversation/conversation-limit-modal").then( + (m) => ({ default: m.ConversationLimitModal }), + ), +); export function ErrorBoundary() { const error = useRouteError(); @@ -76,6 +82,11 @@ export default function MainApp() { const { data: settings } = useSettings(); const { migrateUserConsent } = useMigrateUserConsent(); const config = useConfig(); + const { + isOpen: isConversationLimitModalOpen, + limit: conversationLimit, + closeLimitModal, + } = useConversationLimitStore(); useSyncPostHogConsent(); usePostHogIdentify(); @@ -140,6 +151,15 @@ export default function MainApp() { + + {isConversationLimitModalOpen && ( + + + + )} diff --git a/src/stores/conversation-limit-store.ts b/src/stores/conversation-limit-store.ts new file mode 100644 index 000000000..0c3526ac9 --- /dev/null +++ b/src/stores/conversation-limit-store.ts @@ -0,0 +1,34 @@ +import { create } from "zustand"; + +/** + * Drives the "conversation limit reached" modal. Opened when a cloud backend + * rejects conversation creation for exceeding the user's concurrent-conversation + * limit (see `isConcurrencyLimitError`). `limit` is the cap reported by the + * backend, shown in the modal copy. + */ +interface ConversationLimitState { + isOpen: boolean; + limit: number | null; +} + +interface ConversationLimitActions { + showLimitModal: (limit: number) => void; + closeLimitModal: () => void; +} + +type ConversationLimitStore = ConversationLimitState & ConversationLimitActions; + +const initialState: ConversationLimitState = { + isOpen: false, + limit: null, +}; + +export const useConversationLimitStore = create( + (set) => ({ + ...initialState, + + showLimitModal: (limit: number) => set(() => ({ isOpen: true, limit })), + + closeLimitModal: () => set(() => ({ ...initialState })), + }), +); diff --git a/src/utils/concurrency-limit-error.ts b/src/utils/concurrency-limit-error.ts new file mode 100644 index 000000000..8495546f8 --- /dev/null +++ b/src/utils/concurrency-limit-error.ts @@ -0,0 +1,37 @@ +import { AxiosError } from "axios"; +import { DEFAULT_CONCURRENT_SANDBOX_LIMIT } from "#/utils/constants"; + +interface ConcurrencyLimitErrorDetail { + error: "CONCURRENCY_LIMIT_REACHED"; + message: string; + limit: number; + current: number; +} + +// FastAPI wraps HTTPException detail in a "detail" field. +interface FastAPIErrorResponse { + detail: ConcurrencyLimitErrorDetail; +} + +/** + * True when a request failed because the cloud backend rejected it for + * exceeding the user's concurrent-conversation limit. OpenHands Cloud returns + * this synchronously from `POST /api/v1/app-conversations` as a 429 whose + * `detail.error` is `CONCURRENCY_LIMIT_REACHED`. + */ +export function isConcurrencyLimitError( + error: unknown, +): error is AxiosError { + if (!(error instanceof AxiosError)) return false; + if (error.response?.status !== 429) return false; + return error.response?.data?.detail?.error === "CONCURRENCY_LIMIT_REACHED"; +} + +/** The user's concurrent-conversation limit carried by the error. */ +export function getConcurrencyLimit( + error: AxiosError, +): number { + return ( + error.response?.data?.detail?.limit ?? DEFAULT_CONCURRENT_SANDBOX_LIMIT + ); +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 340d34c1b..2103fbbb8 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -23,6 +23,13 @@ export const PRODUCT_URL = { PRODUCTION: "https://app.all-hands.dev", }; +/** + * Fallback concurrent-conversation limit used when a cloud limit error omits + * the actual limit (matches the Personal Workspace cap). The backend normally + * sends the real limit in the error detail, so this is only a safety net. + */ +export const DEFAULT_CONCURRENT_SANDBOX_LIMIT = 3; + export const SETTINGS_FORM = { LABEL_CLASSNAME: "text-[11px] font-medium leading-4 tracking-[0.11px]", };