From 9439d24d3c86a4c08097d505d0654b2b28a039ce Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 22 May 2026 16:25:47 +0000 Subject: [PATCH 1/2] feat(automations): simplify empty state to a single 'from scratch' card (#732) The automations zero state duplicated the 'Start from a proven workflow' section that already sits below the list. Remove the database icon and 'No automations configured' header, and collapse the two-option 'How to create an automation' panel down to a single 'Create an automation from scratch' card. Clicking the button now opens a new conversation pre-filled with `/openhands-automation create` (reusing the same draft-message pattern as RecommendedAutomationsLauncher) so the plugin command runs immediately. The 'Start from a proven workflow' section continues to sit below the card, giving users two clear paths from the empty state: pick a recommended template, or start from scratch. Fixes #732 Co-authored-by: openhands --- .../automations/create-instructions.test.tsx | 48 ----- .../automations/create-instructions.tsx | 180 ++++++++---------- .../features/automations/empty-state.tsx | 20 -- src/i18n/translation.json | 151 ++++----------- src/routes/automations-list.tsx | 6 +- 5 files changed, 117 insertions(+), 288 deletions(-) delete mode 100644 __tests__/components/automations/create-instructions.test.tsx delete mode 100644 src/components/features/automations/empty-state.tsx diff --git a/__tests__/components/automations/create-instructions.test.tsx b/__tests__/components/automations/create-instructions.test.tsx deleted file mode 100644 index fbfbe0f7e..000000000 --- a/__tests__/components/automations/create-instructions.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import { - NavigationProvider, - type NavigationContextValue, -} from "#/context/navigation-context"; -import { CreateInstructions } from "#/components/features/automations/create-instructions"; -import { I18nKey } from "#/i18n/declaration"; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - [I18nKey.AUTOMATIONS$EMPTY_START_CONVERSATION]: "Start a conversation", - }; - return translations[key] || key; - }, - }), -})); - -function renderCreateInstructions() { - const value: NavigationContextValue = { - currentPath: "/automations", - conversationId: null, - isNavigating: false, - navigate: vi.fn(), - }; - - const result = render( - - - , - ); - - return { ...result, navigate: value.navigate }; -} - -describe("CreateInstructions", () => { - it("navigates to the home route via SPA routing when 'Start a conversation' is clicked", () => { - const { navigate } = renderCreateInstructions(); - - const link = screen.getByRole("link", { name: /start a conversation/i }); - const clickEvent = fireEvent.click(link); - - expect(navigate).toHaveBeenCalledWith("/", { replace: false }); - expect(clickEvent).toBe(false); - }); -}); diff --git a/src/components/features/automations/create-instructions.tsx b/src/components/features/automations/create-instructions.tsx index 25ca5d42e..f43432bde 100644 --- a/src/components/features/automations/create-instructions.tsx +++ b/src/components/features/automations/create-instructions.tsx @@ -1,122 +1,108 @@ -import { useState } from "react"; +import { useRef } from "react"; import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; -import TerminalIcon from "#/icons/terminal.svg?react"; import SparkleIcon from "#/icons/sparkle.svg?react"; -import ChevronDownIcon from "#/icons/chevron-down.svg?react"; -import { cn } from "#/utils/utils"; -import { NavigationLink } from "#/components/shared/navigation-link"; +import { useNavigation } from "#/context/navigation-context"; +import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; +import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation"; +import { useConversationStore } from "#/stores/conversation-store"; +import { + setConversationState, + setPendingTaskDraft, +} from "#/utils/conversation-local-storage"; const DOCS_URL = "https://docs.openhands.dev/openhands/usage/automations/overview"; -const NEW_CONVERSATION_URL = "/"; -const PLUGIN_COMMAND = "/openhands-automation create"; -const PLUGIN_INSTALL_URL = - "https://github.com/OpenHands/extensions#quick-start"; +const AUTOMATION_PROMPT = "/openhands-automation create"; -interface CreateInstructionsProps { - /** If true, the instructions are collapsible and start collapsed */ - collapsible?: boolean; -} - -export function CreateInstructions({ - collapsible = false, -}: CreateInstructionsProps) { +/** + * "Create an automation from scratch" card shown when the automations + * list is empty. Clicking the button starts a new conversation + * pre-filled with `/openhands-automation create` so the OpenHands + * automation plugin command runs immediately. + */ +export function CreateInstructions() { const { t } = useTranslation("openhands"); - const [isExpanded, setIsExpanded] = useState(!collapsible); + const { navigate } = useNavigation(); + const createConversation = useCreateConversation(); + const isCreatingConversation = useIsCreatingConversation(); + const setMessageToSend = useConversationStore( + (state) => state.setMessageToSend, + ); + const launchInFlightRef = useRef(false); - const content = ( - <> -
- {/* Option 1: Claude Code / Codex */} -
-
- - - {t(I18nKey.AUTOMATIONS$EMPTY_OPTION_PLUGIN_TITLE)} - -
-

- - {t(I18nKey.AUTOMATIONS$EMPTY_INSTALL_PLUGIN)} - {" "} - {t(I18nKey.AUTOMATIONS$EMPTY_OPTION_PLUGIN_DESC)} -

- - {PLUGIN_COMMAND} - -
+ const handleStart = () => { + if ( + launchInFlightRef.current || + createConversation.isPending || + isCreatingConversation + ) { + return; + } + launchInFlightRef.current = true; + + createConversation.mutate( + {}, + { + onSuccess: (conversation) => { + if ( + conversation.conversation_id.startsWith("task-") && + conversation.task_id + ) { + setPendingTaskDraft(conversation.task_id, AUTOMATION_PROMPT); + } else { + setConversationState(conversation.conversation_id, { + draftMessage: AUTOMATION_PROMPT, + }); + } + navigate?.(`/conversations/${conversation.conversation_id}`); + window.setTimeout(() => setMessageToSend(AUTOMATION_PROMPT), 0); + }, + onError: () => { + launchInFlightRef.current = false; + }, + }, + ); + }; + + const disabled = createConversation.isPending || isCreatingConversation; - {/* Option 2: OpenHands Cloud conversation */} -
-
- - - {t(I18nKey.AUTOMATIONS$EMPTY_OPTION_CONVERSATION_TITLE)} - -
-

- {t(I18nKey.AUTOMATIONS$EMPTY_OPTION_CONVERSATION_DESC)} -

- - {t(I18nKey.AUTOMATIONS$EMPTY_START_CONVERSATION)} - - + return ( +
+
+
+ + + {t(I18nKey.AUTOMATIONS$CREATE_FROM_SCRATCH_TITLE)} +
+

+ {t(I18nKey.AUTOMATIONS$CREATE_FROM_SCRATCH_DESC)} +

+ + {AUTOMATION_PROMPT} + +
- {/* Documentation link */}

{t(I18nKey.AUTOMATIONS$EMPTY_LEARN_MORE)}

- - ); - - if (collapsible) { - return ( -
- - {isExpanded &&
{content}
} -
- ); - } - - return ( -
-

- {t(I18nKey.AUTOMATIONS$EMPTY_HOW_TO_CREATE_TITLE)} -

- {content}
); } diff --git a/src/components/features/automations/empty-state.tsx b/src/components/features/automations/empty-state.tsx deleted file mode 100644 index 0a4eb0bdd..000000000 --- a/src/components/features/automations/empty-state.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { I18nKey } from "#/i18n/declaration"; -import DatabaseIcon from "#/icons/database.svg?react"; -import { CreateInstructions } from "./create-instructions"; - -export function EmptyState() { - const { t } = useTranslation("openhands"); - - return ( -
- -

{t(I18nKey.AUTOMATIONS$EMPTY)}

- - {/* How to create section */} -
- -
-
- ); -} diff --git a/src/i18n/translation.json b/src/i18n/translation.json index 15b28c2e6..8f04afa78 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -11440,124 +11440,39 @@ "uk": "Переглядайте активні та неактивні автоматизації, шукайте за метаданими та переглядайте деталі лише для читання.", "ca": "Visualitzeu les automatitzacions actives i inactives, cerqueu per metadades i inspeccioneu els detalls de només lectura." }, - "AUTOMATIONS$EMPTY": { - "en": "No automations configured", - "ja": "オートメーションが設定されていません", - "zh-CN": "未配置自动化", - "zh-TW": "未設定自動化", - "ko-KR": "구성된 자동화가 없습니다", - "no": "Ingen automatiseringer konfigurert", - "it": "Nessuna automazione configurata", - "pt": "Nenhuma automação configurada", - "es": "No hay automatizaciones configuradas", - "ar": "لم يتم تكوين أي أتمتة", - "fr": "Aucune automatisation configurée", - "tr": "Yapılandırılmış otomasyon yok", - "de": "Keine Automatisierungen konfiguriert", - "uk": "Автоматизації не налаштовано", - "ca": "No hi ha automatitzacions configurades" - }, - "AUTOMATIONS$EMPTY_HOW_TO_CREATE_TITLE": { - "en": "How to create an automation", - "ja": "オートメーションの作成方法", - "zh-CN": "如何创建自动化", - "zh-TW": "如何建立自動化", - "ko-KR": "자동화를 만드는 방법", - "no": "Slik oppretter du en automatisering", - "it": "Come creare un'automazione", - "pt": "Como criar uma automação", - "es": "Cómo crear una automatización", - "ar": "كيفية إنشاء أتمتة", - "fr": "Comment créer une automatisation", - "tr": "Otomasyon nasıl oluşturulur", - "de": "So erstellen Sie eine Automatisierung", - "uk": "Як створити автоматизацію", - "ca": "Com crear una automatització" - }, - "AUTOMATIONS$EMPTY_OPTION_PLUGIN_TITLE": { - "en": "Using Claude Code or Codex", - "ja": "Claude CodeまたはCodexを使用", - "zh-CN": "使用 Claude Code 或 Codex", - "zh-TW": "使用 Claude Code 或 Codex", - "ko-KR": "Claude Code 또는 Codex 사용", - "no": "Bruke Claude Code eller Codex", - "it": "Utilizzo di Claude Code o Codex", - "pt": "Usando Claude Code ou Codex", - "es": "Uso de Claude Code o Codex", - "ar": "استخدام Claude Code أو Codex", - "fr": "Utilisation de Claude Code ou Codex", - "tr": "Claude Code veya Codex kullanma", - "de": "Claude Code oder Codex verwenden", - "uk": "Використання Claude Code або Codex", - "ca": "Ús de Claude Code o Codex" - }, - "AUTOMATIONS$EMPTY_OPTION_PLUGIN_DESC": { - "en": "and run the command:", - "ja": "以下のコマンドを実行:", - "zh-CN": "并运行以下命令:", - "zh-TW": "並執行下列命令:", - "ko-KR": "그리고 다음 명령을 실행하세요:", - "no": "og kjør kommandoen:", - "it": "ed esegui il comando:", - "pt": "e execute o comando:", - "es": "y ejecute el comando:", - "ar": "ثم قم بتشغيل الأمر:", - "fr": "et exécutez la commande :", - "tr": "ve komutu çalıştırın:", - "de": "und führen Sie den Befehl aus:", - "uk": "та виконайте команду:", - "ca": "i executeu l'ordre:" - }, - "AUTOMATIONS$EMPTY_INSTALL_PLUGIN": { - "en": "Install the OpenHands plugin", - "ja": "OpenHandsプラグインをインストール", - "zh-CN": "安装 OpenHands 插件", - "zh-TW": "安裝 OpenHands 外掛", - "ko-KR": "OpenHands 플러그인 설치", - "no": "Installer OpenHands-plugin", - "it": "Installa il plugin OpenHands", - "pt": "Instalar o plugin OpenHands", - "es": "Instalar el complemento OpenHands", - "ar": "تثبيت إضافة OpenHands", - "fr": "Installer le plugin OpenHands", - "tr": "OpenHands eklentisini yükle", - "de": "OpenHands-Plugin installieren", - "uk": "Встановити плагін OpenHands", - "ca": "Instal·la el connector OpenHands" - }, - "AUTOMATIONS$EMPTY_OPTION_CONVERSATION_TITLE": { - "en": "Using OpenHands Cloud", - "ja": "OpenHands Cloudを使用", - "zh-CN": "使用 OpenHands Cloud", - "zh-TW": "使用 OpenHands Cloud", - "ko-KR": "OpenHands Cloud 사용", - "no": "Bruke OpenHands Cloud", - "it": "Utilizzo di OpenHands Cloud", - "pt": "Usando OpenHands Cloud", - "es": "Uso de OpenHands Cloud", - "ar": "استخدام OpenHands Cloud", - "fr": "Utilisation d'OpenHands Cloud", - "tr": "OpenHands Cloud kullanma", - "de": "OpenHands Cloud verwenden", - "uk": "Використання OpenHands Cloud", - "ca": "Ús d'OpenHands Cloud" - }, - "AUTOMATIONS$EMPTY_OPTION_CONVERSATION_DESC": { - "en": "Start a new conversation and ask OpenHands to create an automation for you.", - "ja": "新しい会話を開始し、OpenHandsにオートメーションの作成を依頼してください。", - "zh-CN": "开始新对话,请 OpenHands 为您创建自动化。", - "zh-TW": "開始新對話,並請 OpenHands 為您建立自動化。", - "ko-KR": "새 대화를 시작하고 OpenHands에 자동화 생성을 요청하세요.", - "no": "Start en ny samtale og be OpenHands om å opprette en automatisering for deg.", - "it": "Avvia una nuova conversazione e chiedi a OpenHands di creare un'automazione per te.", - "pt": "Inicie uma nova conversa e peça ao OpenHands para criar uma automação para você.", - "es": "Inicie una nueva conversación y pídale a OpenHands que cree una automatización para usted.", - "ar": "ابدأ محادثة جديدة واطلب من OpenHands إنشاء أتمتة لك.", - "fr": "Démarrez une nouvelle conversation et demandez à OpenHands de créer une automatisation pour vous.", - "tr": "Yeni bir konuşma başlatın ve OpenHands'ten sizin için bir otomasyon oluşturmasını isteyin.", - "de": "Starten Sie eine neue Konversation und bitten Sie OpenHands, eine Automatisierung für Sie zu erstellen.", - "uk": "Розпочніть нову розмову та попросіть OpenHands створити для вас автоматизацію.", - "ca": "Inicieu una nova conversa i demaneu a OpenHands que creï una automatització per a vós." + "AUTOMATIONS$CREATE_FROM_SCRATCH_TITLE": { + "en": "Create an automation from scratch", + "ja": "ゼロからオートメーションを作成", + "zh-CN": "从头创建自动化", + "zh-TW": "從頭開始建立自動化", + "ko-KR": "처음부터 자동화 만들기", + "no": "Lag en automatisering fra bunnen av", + "it": "Crea un'automazione da zero", + "pt": "Crie uma automação do zero", + "es": "Crea una automatización desde cero", + "ar": "إنشاء أتمتة من الصفر", + "fr": "Créer une automatisation à partir de zéro", + "tr": "Sıfırdan otomasyon oluştur", + "de": "Automatisierung von Grund auf erstellen", + "uk": "Створити автоматизацію з нуля", + "ca": "Crea una automatització des de zero" + }, + "AUTOMATIONS$CREATE_FROM_SCRATCH_DESC": { + "en": "Start a new conversation pre-filled with this command:", + "ja": "次のコマンドが入力済みの新しい会話を開始します:", + "zh-CN": "开始一个新对话,并预填以下命令:", + "zh-TW": "開始一個新對話,並預先填入此指令:", + "ko-KR": "다음 명령이 미리 입력된 새 대화를 시작합니다:", + "no": "Start en ny samtale ferdigutfylt med denne kommandoen:", + "it": "Avvia una nuova conversazione precompilata con questo comando:", + "pt": "Inicie uma nova conversa pré-preenchida com este comando:", + "es": "Inicia una nueva conversación prerrellenada con este comando:", + "ar": "ابدأ محادثة جديدة معبأة مسبقًا بهذا الأمر:", + "fr": "Démarrer une nouvelle conversation préremplie avec cette commande :", + "tr": "Bu komutla önceden doldurulmuş yeni bir konuşma başlat:", + "de": "Neue Unterhaltung mit diesem vorausgefüllten Befehl starten:", + "uk": "Розпочати нову розмову, попередньо заповнену цією командою:", + "ca": "Inicia una nova conversa emplenada prèviament amb aquesta ordre:" }, "AUTOMATIONS$EMPTY_START_CONVERSATION": { "en": "Start a conversation", diff --git a/src/routes/automations-list.tsx b/src/routes/automations-list.tsx index 6baee8325..045c83181 100644 --- a/src/routes/automations-list.tsx +++ b/src/routes/automations-list.tsx @@ -11,7 +11,6 @@ import { useActiveBackend } from "#/contexts/active-backend-context"; import { SearchInput } from "#/components/features/automations/search-input"; import { AutomationGroup } from "#/components/features/automations/automation-group"; import { AutomationCardSkeleton } from "#/components/features/automations/automation-card-skeleton"; -import { EmptyState } from "#/components/features/automations/empty-state"; import { ErrorState } from "#/components/features/automations/error-state"; import { BackendNotConfigured } from "#/components/features/automations/backend-not-configured"; import { DeleteConfirmationModal } from "#/components/features/automations/delete-confirmation-modal"; @@ -170,14 +169,11 @@ export default function AutomationsList() { {isError && !isLoading && } {!isLoading && !isError && data?.automations.length === 0 && ( - + )} {!isLoading && !isError && data && data.automations.length > 0 && ( <> - {/* Collapsible creation instructions at the top */} - - Date: Fri, 22 May 2026 17:14:51 +0000 Subject: [PATCH 2/2] create-instructions: reset launchInFlightRef in onSuccess Addresses the all-hands-bot review on PR #736: the in-flight guard was only released on error. If `navigate` resolved to the default noop (no NavigationProvider in the tree), the component would stay mounted and the button would be permanently disabled. Reset the ref at the end of onSuccess so the button stays usable. Co-authored-by: openhands --- src/components/features/automations/create-instructions.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/features/automations/create-instructions.tsx b/src/components/features/automations/create-instructions.tsx index f43432bde..b8903556d 100644 --- a/src/components/features/automations/create-instructions.tsx +++ b/src/components/features/automations/create-instructions.tsx @@ -57,6 +57,10 @@ export function CreateInstructions() { } navigate?.(`/conversations/${conversation.conversation_id}`); window.setTimeout(() => setMessageToSend(AUTOMATION_PROMPT), 0); + // Reset the re-entry guard in case the component stays mounted + // (e.g. `navigate` is a noop because no NavigationProvider is + // wired up); otherwise the button would be permanently disabled. + launchInFlightRef.current = false; }, onError: () => { launchInFlightRef.current = false;