From 37063d0d2d2e3d662c35d4f9b9dd0fe8680e86c7 Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Fri, 29 May 2026 10:59:53 -0700 Subject: [PATCH] feat(automations): add deployment choice modal for responder automations When launching a GitHub or Slack "responder" automation on a local backend, show a modal explaining the two runtime options (poll locally vs. OpenHands Cloud) so users pick the right path before configuring. Includes a "don't show this again" preference, a docs link for self-hosting a cloud backend, and the cloud connect CTA. Closes #868 Co-authored-by: Cursor --- .../recommended-automations.test.tsx | 96 +++++++++- __tests__/utils/automation-responder.test.ts | 46 +++++ .../automations/deployment-choice-modal.tsx | 177 ++++++++++++++++++ .../recommended-automations-launcher.tsx | 49 ++++- src/i18n/translation.json | 153 +++++++++++++++ src/stores/automation-preferences-store.ts | 45 +++++ src/utils/automation-responder.ts | 25 +++ src/utils/constants.ts | 7 + 8 files changed, 588 insertions(+), 10 deletions(-) create mode 100644 __tests__/utils/automation-responder.test.ts create mode 100644 src/components/features/automations/deployment-choice-modal.tsx create mode 100644 src/stores/automation-preferences-store.ts create mode 100644 src/utils/automation-responder.ts diff --git a/__tests__/components/automations/recommended-automations.test.tsx b/__tests__/components/automations/recommended-automations.test.tsx index 59fa4e613..4992e9148 100644 --- a/__tests__/components/automations/recommended-automations.test.tsx +++ b/__tests__/components/automations/recommended-automations.test.tsx @@ -23,6 +23,7 @@ import { AUTOMATION_CATALOG, type RecommendedAutomation, } from "@openhands/extensions/automations"; +import { useAutomationPreferencesStore } from "#/stores/automation-preferences-store"; const { mockCreateConversationMutate, mockUseSettings } = vi.hoisted(() => ({ mockCreateConversationMutate: vi.fn(), @@ -37,6 +38,8 @@ vi.mock("react-i18next", () => ({ return key; }, }), + // Minimal stub: render the key so components using don't crash. + Trans: ({ i18nKey }: { i18nKey?: string }) => i18nKey ?? null, })); vi.mock("#/hooks/mutation/use-create-conversation", () => ({ @@ -108,6 +111,11 @@ describe("recommended automations", () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); + // The persisted preferences store is a module singleton — reset its + // in-memory state so a "don't show again" set in one test doesn't leak. + useAutomationPreferencesStore.setState({ + hideResponderDeploymentChoice: false, + }); __resetActiveStoreForTests(); setRegisteredBackends([localBackend]); setActiveSelection({ backendId: localBackend.id }); @@ -292,10 +300,10 @@ describe("recommended automations", () => { ); expect(plusBadge.tagName).toBe("SPAN"); expect(plusBadge).toHaveAttribute("aria-hidden", "true"); - expect(plusBadge.className).toContain("hover:bg-[var(--oh-interactive-hover)]"); - expect( - plusBadge.querySelector('[role="switch"]'), - ).not.toBeInTheDocument(); + expect(plusBadge.className).toContain( + "hover:bg-[var(--oh-interactive-hover)]", + ); + expect(plusBadge.querySelector('[role="switch"]')).not.toBeInTheDocument(); }); it("selects a recommendation directly from its card", () => { @@ -324,6 +332,8 @@ describe("recommended automations", () => { fireEvent.click( screen.getByTestId("recommended-automation-card-github-pr-reviewer"), ); + // GitHub responders first prompt for a runtime choice. + fireEvent.click(screen.getByTestId("deployment-choice-local")); const modal = await screen.findByTestId("mcp-install-modal"); expect(modal).toHaveAttribute("data-marketplace-id", "github"); @@ -343,6 +353,7 @@ describe("recommended automations", () => { fireEvent.click( screen.getByTestId("recommended-automation-card-github-pr-reviewer"), ); + fireEvent.click(screen.getByTestId("deployment-choice-local")); expect(mockCreateConversationMutate).toHaveBeenCalledTimes(1); expect(screen.queryByTestId("mcp-install-modal")).not.toBeInTheDocument(); @@ -368,6 +379,8 @@ describe("recommended automations", () => { "recommended-automation-card-github-pr-reviewer", ); fireEvent.click(card); + fireEvent.click(screen.getByTestId("deployment-choice-local")); + // Launch is now in flight; clicking the card again must not relaunch. fireEvent.click(card); expect(mockCreateConversationMutate).toHaveBeenCalledTimes(1); @@ -384,6 +397,80 @@ describe("recommended automations", () => { ).not.toBeInTheDocument(); }); + it("prompts for a runtime choice before launching a GitHub/Slack responder", () => { + mockUseSettings.mockReturnValue({ + data: settingsWithGithubMcp(), + }); + + renderLauncher(); + + fireEvent.click( + screen.getByTestId("recommended-automation-card-github-pr-reviewer"), + ); + + // The deployment-choice modal gates the launch — nothing happens yet. + expect(screen.getByTestId("deployment-choice-modal")).toBeInTheDocument(); + expect(mockCreateConversationMutate).not.toHaveBeenCalled(); + expect(screen.queryByTestId("mcp-install-modal")).not.toBeInTheDocument(); + }); + + it("persists 'Don't show this again' and skips the modal next time", () => { + mockUseSettings.mockReturnValue({ + data: settingsWithGithubMcp(), + }); + + const { unmount } = renderLauncher(); + + fireEvent.click( + screen.getByTestId("recommended-automation-card-github-pr-reviewer"), + ); + expect(screen.getByTestId("deployment-choice-modal")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("deployment-choice-dont-show-again")); + expect( + useAutomationPreferencesStore.getState().hideResponderDeploymentChoice, + ).toBe(true); + + fireEvent.click(screen.getByTestId("deployment-choice-local")); + expect(mockCreateConversationMutate).toHaveBeenCalledTimes(1); + + // A fresh launcher mount now bypasses the modal and launches directly. + unmount(); + renderLauncher(); + + fireEvent.click( + screen.getByTestId("recommended-automation-card-github-repo-monitor"), + ); + + expect( + screen.queryByTestId("deployment-choice-modal"), + ).not.toBeInTheDocument(); + expect(mockCreateConversationMutate).toHaveBeenCalledTimes(2); + }); + + it("links the cloud option to OpenHands Cloud integrations and dismisses on click", () => { + renderLauncher(); + + fireEvent.click( + screen.getByTestId("recommended-automation-card-github-pr-reviewer"), + ); + + const cloudLink = screen.getByTestId("deployment-choice-cloud"); + expect(cloudLink).toHaveAttribute( + "href", + "https://app.all-hands.dev/settings/integrations", + ); + expect(cloudLink).toHaveAttribute("target", "_blank"); + + fireEvent.click(cloudLink); + + // Choosing cloud does not start a local conversation and closes the modal. + expect(mockCreateConversationMutate).not.toHaveBeenCalled(); + expect( + screen.queryByTestId("deployment-choice-modal"), + ).not.toBeInTheDocument(); + }); + it("launches the recommendation after the missing MCP is installed", async () => { const saveSpy = vi .spyOn(SettingsService, "saveSettings") @@ -394,6 +481,7 @@ describe("recommended automations", () => { fireEvent.click( screen.getByTestId("recommended-automation-card-github-pr-reviewer"), ); + fireEvent.click(screen.getByTestId("deployment-choice-local")); await screen.findByTestId("mcp-install-modal"); fireEvent.change( diff --git a/__tests__/utils/automation-responder.test.ts b/__tests__/utils/automation-responder.test.ts new file mode 100644 index 000000000..2bd708052 --- /dev/null +++ b/__tests__/utils/automation-responder.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { AUTOMATION_CATALOG } from "@openhands/extensions/automations"; +import { + isResponderAutomation, + RESPONDER_INTEGRATION_IDS, +} from "#/utils/automation-responder"; + +function byId(id: string) { + const automation = AUTOMATION_CATALOG.find((item) => item.id === id); + if (!automation) throw new Error(`missing catalog automation: ${id}`); + return automation; +} + +describe("isResponderAutomation", () => { + it("treats GitHub automations as responders", () => { + expect(isResponderAutomation(byId("github-pr-reviewer"))).toBe(true); + expect(isResponderAutomation(byId("github-repo-monitor"))).toBe(true); + }); + + it("treats Slack automations as responders", () => { + expect(isResponderAutomation(byId("slack-channel-monitor"))).toBe(true); + expect(isResponderAutomation(byId("slack-standup-digest"))).toBe(true); + }); + + it("treats automations without GitHub/Slack as non-responders", () => { + expect( + isResponderAutomation({ requiredIntegrationIds: ["tavily", "notion"] }), + ).toBe(false); + expect(isResponderAutomation({ requiredIntegrationIds: ["linear"] })).toBe( + false, + ); + expect(isResponderAutomation({ requiredIntegrationIds: [] })).toBe(false); + }); + + it("flags an automation that requires Slack alongside other integrations", () => { + expect( + isResponderAutomation({ + requiredIntegrationIds: ["slack", "linear", "notion"], + }), + ).toBe(true); + }); + + it("exposes the github/slack responder integration ids", () => { + expect(RESPONDER_INTEGRATION_IDS).toEqual(["github", "slack"]); + }); +}); diff --git a/src/components/features/automations/deployment-choice-modal.tsx b/src/components/features/automations/deployment-choice-modal.tsx new file mode 100644 index 000000000..46658348f --- /dev/null +++ b/src/components/features/automations/deployment-choice-modal.tsx @@ -0,0 +1,177 @@ +import { type ReactNode } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { Cloud, Laptop } from "lucide-react"; +import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalCloseButton } from "#/components/shared/modals/modal-close-button"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { + MODAL_MAX_WIDTH_VIEWPORT, + modalWidthClassName, +} from "#/components/shared/modals/modal-body"; +import { formControlButtonClassName } from "#/utils/form-control-classes"; +import { + OPENHANDS_CLOUD_INTEGRATIONS_URL, + OPENHANDS_SELF_HOSTED_DOCS_URL, +} from "#/utils/constants"; +import { useAutomationPreferencesStore } from "#/stores/automation-preferences-store"; +import { I18nKey } from "#/i18n/declaration"; +import { cn } from "#/utils/utils"; + +interface DeploymentChoiceModalProps { + /** Proceed with the existing local automation setup flow. */ + onContinueLocal: () => void; + /** Dismiss the modal without launching (also called after the cloud link). */ + onClose: () => void; +} + +const CHOICE_CARD_CLASSNAME = + "flex flex-1 flex-col gap-3 rounded-xl border border-[var(--oh-border)] p-4"; + +/** Inline docs link used inside the {@link Trans} description. */ +function SelfHostDocsLink({ children }: { children?: ReactNode }) { + return ( + + {children} + + ); +} + +/** + * Explains the two runtime options for an event-driven GitHub / Slack + * "responder" automation before the user invests time configuring it: + * + * - Poll locally: keeps everything on the user's machine, but only runs while + * the laptop is awake and Agent Canvas is running. + * - OpenHands Cloud: keeps responding even when the laptop is closed. + * + * Today this is only surfaced for the **local** backend (the recommended + * automations launcher is local-only). The component is intentionally generic + * so the future Local / User Cloud / OpenHands Cloud switch (issue #868) can + * reuse it without re-templating the copy. + */ +export function DeploymentChoiceModal({ + onContinueLocal, + onClose, +}: DeploymentChoiceModalProps) { + const { t } = useTranslation("openhands"); + const hideResponderDeploymentChoice = useAutomationPreferencesStore( + (state) => state.hideResponderDeploymentChoice, + ); + const setHideResponderDeploymentChoice = useAutomationPreferencesStore( + (state) => state.setHideResponderDeploymentChoice, + ); + + return ( + +
+ + +
+

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

+

+ }} + /> +

+
+ +
+ {/* Left: poll locally */} +
+
+ +

+ {t(I18nKey.DEPLOYMENT_CHOICE$LOCAL_TITLE)} +

+
+

+ {t(I18nKey.DEPLOYMENT_CHOICE$LOCAL_DESCRIPTION)} +

+ + {t(I18nKey.DEPLOYMENT_CHOICE$LOCAL_ACTION)} + +
+ + {/* Right: OpenHands Cloud */} +
+
+ +

+ {t(I18nKey.DEPLOYMENT_CHOICE$CLOUD_TITLE)} +

+
+

+ {t(I18nKey.DEPLOYMENT_CHOICE$CLOUD_DESCRIPTION)} +

+ + + {t(I18nKey.DEPLOYMENT_CHOICE$CLOUD_ACTION)} + +
+
+ +
+ +
+
+
+ ); +} diff --git a/src/components/features/automations/recommended-automations-launcher.tsx b/src/components/features/automations/recommended-automations-launcher.tsx index 79239b6cb..df53cd6f2 100644 --- a/src/components/features/automations/recommended-automations-launcher.tsx +++ b/src/components/features/automations/recommended-automations-launcher.tsx @@ -22,7 +22,10 @@ import { getMarketplaceEntryById, } from "#/utils/mcp-marketplace-utils"; import { InstallServerModal } from "#/components/features/mcp-page/install-server-modal"; +import { isResponderAutomation } from "#/utils/automation-responder"; +import { useAutomationPreferencesStore } from "#/stores/automation-preferences-store"; import { RecommendedAutomationsSection } from "./recommended-automations-section"; +import { DeploymentChoiceModal } from "./deployment-choice-modal"; interface RecommendedAutomationsLauncherProps { query?: string; @@ -89,8 +92,13 @@ export function RecommendedAutomationsLauncher({ const setMessageToSend = useConversationStore( (state) => state.setMessageToSend, ); + const hideResponderDeploymentChoice = useAutomationPreferencesStore( + (state) => state.hideResponderDeploymentChoice, + ); const [pendingAutomation, setPendingAutomation] = useState(null); + const [deploymentChoice, setDeploymentChoice] = + useState(null); const [installQueue, setInstallQueue] = useState([]); const completedInstallRef = useRef(false); const launchInFlightRef = useRef(false); @@ -161,24 +169,42 @@ export function RecommendedAutomationsLauncher({ [installedMcpServers], ); + // Local automation setup: install any missing MCP servers first, then launch. + const proceedWithLocalSetup = useCallback( + (automation: RecommendedAutomation) => { + const missingEntries = getMissingEntries(automation); + if (missingEntries.length === 0) { + launchAutomation(automation); + return; + } + + setPendingAutomation(automation); + setInstallQueue(missingEntries); + }, + [getMissingEntries, launchAutomation], + ); + const handleSelectAutomation = (automation: RecommendedAutomation) => { if ( launchInFlightRef.current || createConversation.isPending || isCreatingConversation || - installQueue.length > 0 + installQueue.length > 0 || + deploymentChoice ) { return; } - const missingEntries = getMissingEntries(automation); - if (missingEntries.length === 0) { - launchAutomation(automation); + // GitHub / Slack responders are event-driven: local polling stops when the + // laptop is off, so first let the user pick between local and OpenHands + // Cloud before they invest time configuring the automation (issue #868). + // Skipped once the user opts out via the modal's "Don't show this again". + if (isResponderAutomation(automation) && !hideResponderDeploymentChoice) { + setDeploymentChoice(automation); return; } - setPendingAutomation(automation); - setInstallQueue(missingEntries); + proceedWithLocalSetup(automation); }; const cancelInstallFlow = () => { @@ -223,6 +249,17 @@ export function RecommendedAutomationsLauncher({ onSelect={handleSelectAutomation} /> + {deploymentChoice && ( + { + const automation = deploymentChoice; + setDeploymentChoice(null); + proceedWithLocalSetup(automation); + }} + onClose={() => setDeploymentChoice(null)} + /> + )} + {installEntry && ( self-host a cloud backend to keep them running continuously.", + "ja": "Slack、GitHub などのレスポンダーはローカルで実行できますが、デバイスがオフラインのときはイベントを取りこぼす可能性があります。OpenHands Cloud に接続するか、クラウドバックエンドをセルフホストして、継続的に実行し続けましょう。", + "zh-CN": "Slack、GitHub 等响应器可以在本地运行,但当您的设备离线时可能会错过事件。请连接到 OpenHands Cloud 或自托管云后端,以保持它们持续运行。", + "zh-TW": "Slack、GitHub 等回應器可以在本機執行,但當您的裝置離線時可能會錯過事件。請連接到 OpenHands Cloud 或自架雲端後端,以保持它們持續執行。", + "ko-KR": "Slack, GitHub 및 유사한 응답기는 로컬에서 실행할 수 있지만 기기가 오프라인일 때 이벤트를 놓칠 수 있습니다. OpenHands Cloud에 연결하거나 클라우드 백엔드를 자체 호스팅하여 지속적으로 실행되도록 하세요.", + "no": "Slack-, GitHub- og lignende respondere kan kjøre lokalt, men kan gå glipp av hendelser når enheten din er frakoblet. Koble til OpenHands Cloud eller vert et skybackend selv for å holde dem i gang kontinuerlig.", + "it": "I responder di Slack, GitHub e simili possono essere eseguiti localmente, ma potrebbero perdere eventi quando il dispositivo è offline. Connettiti a OpenHands Cloud o esegui un backend cloud self-hosted per mantenerli sempre attivi.", + "pt": "Os responders do Slack, GitHub e semelhantes podem ser executados localmente, mas podem perder eventos quando o seu dispositivo está offline. Ligue-se ao OpenHands Cloud ou hospede um backend de nuvem por conta própria para mantê-los a funcionar continuamente.", + "es": "Los responders de Slack, GitHub y similares pueden ejecutarse localmente, pero pueden perder eventos cuando tu dispositivo está sin conexión. Conéctate a OpenHands Cloud o autoaloja un backend en la nube para mantenerlos funcionando de forma continua.", + "ar": "يمكن تشغيل مستجيبات Slack وGitHub وما شابهها محليًا، لكنها قد تفوّت الأحداث عندما يكون جهازك غير متصل. اتصل بـ OpenHands Cloud أو استضف خادمًا سحابيًا ذاتيًا لإبقائها تعمل باستمرار.", + "fr": "Les répondeurs Slack, GitHub et similaires peuvent s'exécuter localement, mais peuvent manquer des événements lorsque votre appareil est hors ligne. Connectez-vous à OpenHands Cloud ou auto-hébergez un backend cloud pour les maintenir en fonctionnement continu.", + "tr": "Slack, GitHub ve benzeri yanıtlayıcılar yerel olarak çalışabilir, ancak cihazınız çevrimdışıyken olayları kaçırabilir. Sürekli çalışmalarını sağlamak için OpenHands Cloud'a bağlanın veya bir bulut arka ucunu kendiniz barındırın.", + "de": "Slack-, GitHub- und ähnliche Responder können lokal ausgeführt werden, verpassen aber möglicherweise Ereignisse, wenn Ihr Gerät offline ist. Verbinden Sie sich mit OpenHands Cloud oder hosten Sie ein Cloud-Backend selbst, um sie kontinuierlich laufen zu lassen.", + "uk": "Відповідачі Slack, GitHub та подібні можуть працювати локально, але можуть пропускати події, коли ваш пристрій офлайн. Підключіться до OpenHands Cloud або розгорніть власний хмарний бекенд, щоб вони працювали безперервно.", + "ca": "Els responders de Slack, GitHub i similars poden executar-se localment, però poden perdre esdeveniments quan el teu dispositiu està desconnectat. Connecta't a OpenHands Cloud o autoallotja un backend al núvol per mantenir-los funcionant contínuament." + }, + "DEPLOYMENT_CHOICE$LOCAL_TITLE": { + "en": "Setup locally", + "ja": "ローカルでセットアップ", + "zh-CN": "本地设置", + "zh-TW": "本機設定", + "ko-KR": "로컬로 설정", + "no": "Sett opp lokalt", + "it": "Configura localmente", + "pt": "Configurar localmente", + "es": "Configurar localmente", + "ar": "الإعداد محليًا", + "fr": "Configurer localement", + "tr": "Yerel olarak kur", + "de": "Lokal einrichten", + "uk": "Налаштувати локально", + "ca": "Configura localment" + }, + "DEPLOYMENT_CHOICE$LOCAL_DESCRIPTION": { + "en": "Keeps everything on your machine, but stops polling when your laptop is off.", + "ja": "すべてを自分のマシン上に保持しますが、ノートパソコンの電源が切れるとポーリングが停止します。", + "zh-CN": "将所有内容保留在您的设备上,但在笔记本电脑关闭时停止轮询。", + "zh-TW": "將所有內容保留在您的裝置上,但在筆記型電腦關閉時停止輪詢。", + "ko-KR": "모든 것을 기기에 보관하지만 노트북이 꺼지면 폴링이 중지됩니다.", + "no": "Beholder alt på maskinen din, men stopper spørringen når den bærbare datamaskinen er av.", + "it": "Mantiene tutto sul tuo dispositivo, ma interrompe il polling quando il laptop è spento.", + "pt": "Mantém tudo na sua máquina, mas para de consultar quando o seu portátil está desligado.", + "es": "Mantiene todo en tu máquina, pero deja de consultar cuando tu portátil está apagado.", + "ar": "يبقي كل شيء على جهازك، لكنه يتوقف عن الاستقصاء عند إيقاف تشغيل حاسوبك المحمول.", + "fr": "Conserve tout sur votre machine, mais arrête l'interrogation lorsque votre ordinateur portable est éteint.", + "tr": "Her şeyi cihazınızda tutar, ancak dizüstü bilgisayarınız kapalıyken sorgulamayı durdurur.", + "de": "Behält alles auf Ihrem Gerät, beendet aber die Abfrage, wenn Ihr Laptop ausgeschaltet ist.", + "uk": "Зберігає все на вашому пристрої, але припиняє опитування, коли ваш ноутбук вимкнений.", + "ca": "Manté tot a la teva màquina, però atura el sondeig quan el teu portàtil està apagat." + }, + "DEPLOYMENT_CHOICE$LOCAL_ACTION": { + "en": "Continue with local setup", + "ja": "ローカル設定で続行", + "zh-CN": "继续本地设置", + "zh-TW": "繼續本機設定", + "ko-KR": "로컬 설정으로 계속", + "no": "Fortsett med lokalt oppsett", + "it": "Continua con la configurazione locale", + "pt": "Continuar com a configuração local", + "es": "Continuar con la configuración local", + "ar": "المتابعة بالإعداد المحلي", + "fr": "Continuer avec la configuration locale", + "tr": "Yerel kurulumla devam et", + "de": "Mit lokaler Einrichtung fortfahren", + "uk": "Продовжити з локальним налаштуванням", + "ca": "Continua amb la configuració local" + }, + "DEPLOYMENT_CHOICE$CLOUD_TITLE": { + "en": "Use OpenHands Cloud", + "ja": "OpenHands Cloud を使用", + "zh-CN": "使用 OpenHands Cloud", + "zh-TW": "使用 OpenHands Cloud", + "ko-KR": "OpenHands Cloud 사용", + "no": "Bruk OpenHands Cloud", + "it": "Usa OpenHands Cloud", + "pt": "Usar o OpenHands Cloud", + "es": "Usar OpenHands Cloud", + "ar": "استخدام OpenHands Cloud", + "fr": "Utiliser OpenHands Cloud", + "tr": "OpenHands Cloud'u kullan", + "de": "OpenHands Cloud verwenden", + "uk": "Використовувати OpenHands Cloud", + "ca": "Utilitza OpenHands Cloud" + }, + "DEPLOYMENT_CHOICE$CLOUD_DESCRIPTION": { + "en": "Always responds even when your laptop is closed.", + "ja": "ノートパソコンを閉じていても常に応答します。", + "zh-CN": "即使合上笔记本电脑也始终响应。", + "zh-TW": "即使闔上筆記型電腦也始終回應。", + "ko-KR": "노트북을 닫아도 항상 응답합니다.", + "no": "Svarer alltid, selv når den bærbare datamaskinen er lukket.", + "it": "Risponde sempre, anche quando il tuo laptop è chiuso.", + "pt": "Responde sempre, mesmo quando o seu portátil está fechado.", + "es": "Responde siempre, incluso cuando tu portátil está cerrado.", + "ar": "يستجيب دائمًا حتى عندما يكون حاسوبك المحمول مغلقًا.", + "fr": "Répond toujours, même lorsque votre ordinateur portable est fermé.", + "tr": "Dizüstü bilgisayarınız kapalı olsa bile her zaman yanıt verir.", + "de": "Antwortet immer, selbst wenn Ihr Laptop geschlossen ist.", + "uk": "Завжди відповідає, навіть коли ваш ноутбук закритий.", + "ca": "Respon sempre, fins i tot quan el teu portàtil està tancat." + }, + "DEPLOYMENT_CHOICE$CLOUD_ACTION": { + "en": "Connect to OpenHands", + "ja": "OpenHands に接続", + "zh-CN": "连接到 OpenHands", + "zh-TW": "連接到 OpenHands", + "ko-KR": "OpenHands에 연결", + "no": "Koble til OpenHands", + "it": "Connetti a OpenHands", + "pt": "Ligar ao OpenHands", + "es": "Conectar a OpenHands", + "ar": "الاتصال بـ OpenHands", + "fr": "Se connecter à OpenHands", + "tr": "OpenHands'e bağlan", + "de": "Mit OpenHands verbinden", + "uk": "Підключитися до OpenHands", + "ca": "Connecta't a OpenHands" + }, + "DEPLOYMENT_CHOICE$DONT_SHOW_AGAIN": { + "en": "Don't show this again", + "ja": "今後表示しない", + "zh-CN": "不再显示", + "zh-TW": "不再顯示", + "ko-KR": "다시 표시하지 않기", + "no": "Ikke vis dette igjen", + "it": "Non mostrare più", + "pt": "Não mostrar novamente", + "es": "No volver a mostrar", + "ar": "عدم العرض مرة أخرى", + "fr": "Ne plus afficher", + "tr": "Bunu bir daha gösterme", + "de": "Nicht mehr anzeigen", + "uk": "Більше не показувати", + "ca": "No ho tornis a mostrar" + }, "AUTOMATIONS$EMPTY": { "en": "No automations configured", "ja": "オートメーションが設定されていません", diff --git a/src/stores/automation-preferences-store.ts b/src/stores/automation-preferences-store.ts new file mode 100644 index 000000000..fa78b952f --- /dev/null +++ b/src/stores/automation-preferences-store.ts @@ -0,0 +1,45 @@ +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; + +/** + * User display preferences for the automations feature. Persisted to + * localStorage (same `zustand/persist` pattern as the other preference + * stores) so dismissals survive reloads. + */ +interface AutomationPreferencesState { + /** + * When true, the GitHub/Slack responder deployment-choice modal is skipped + * and selecting a responder proceeds straight to local setup. Set via the + * modal's "Don't show this again" checkbox (issue #868). + */ + hideResponderDeploymentChoice: boolean; +} + +interface AutomationPreferencesActions { + setHideResponderDeploymentChoice: (value: boolean) => void; +} + +type AutomationPreferencesStore = AutomationPreferencesState & + AutomationPreferencesActions; + +const initialState: AutomationPreferencesState = { + hideResponderDeploymentChoice: false, +}; + +export const useAutomationPreferencesStore = + create()( + persist( + (set) => ({ + ...initialState, + setHideResponderDeploymentChoice: (value) => + set(() => ({ hideResponderDeploymentChoice: value })), + }), + { + name: "automation-preferences", + storage: createJSONStorage(() => localStorage), + partialize: (state): AutomationPreferencesState => ({ + hideResponderDeploymentChoice: state.hideResponderDeploymentChoice, + }), + }, + ), + ); diff --git a/src/utils/automation-responder.ts b/src/utils/automation-responder.ts new file mode 100644 index 000000000..97b8b9b47 --- /dev/null +++ b/src/utils/automation-responder.ts @@ -0,0 +1,25 @@ +import type { RecommendedAutomation } from "@openhands/extensions/automations"; + +/** + * Integration ids whose automations behave like always-on "responders": + * they listen for GitHub / Slack events (or poll for them) and react. These + * are the cases where local polling has a meaningful limitation — it only + * runs while the user's laptop is awake — so the user should be offered the + * OpenHands Cloud runtime as an alternative before they configure the + * automation. See issue #868. + */ +export const RESPONDER_INTEGRATION_IDS = ["github", "slack"] as const; + +/** + * A "responder" automation is one whose required integrations include GitHub + * or Slack. We key off `requiredIntegrationIds` rather than the catalog `id` + * so new GitHub/Slack templates are covered automatically without touching + * this list. + */ +export function isResponderAutomation( + automation: Pick, +): boolean { + return automation.requiredIntegrationIds.some((id) => + (RESPONDER_INTEGRATION_IDS as readonly string[]).includes(id), + ); +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index b22362e65..b5caea8d6 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -42,6 +42,13 @@ export const PRODUCT_URL = { PRODUCTION: "https://app.all-hands.dev", }; +/** OpenHands Cloud integrations settings page (GitHub / Slack responders). */ +export const OPENHANDS_CLOUD_INTEGRATIONS_URL = `${PRODUCT_URL.PRODUCTION}/settings/integrations`; + +/** Docs for running an always-on, self-hosted OpenHands backend. */ +export const OPENHANDS_SELF_HOSTED_DOCS_URL = + "https://docs.openhands.dev/openhands/usage/agent-canvas/backends"; + export const SETTINGS_FORM = { LABEL_CLASSNAME: "text-[11px] font-medium leading-4 tracking-[0.11px]", };