diff --git a/__tests__/components/automations/create-instructions.test.tsx b/__tests__/components/automations/create-instructions.test.tsx deleted file mode 100644 index fbfbe0f7..00000000 --- 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 25ca5d42..b8903556 100644 --- a/src/components/features/automations/create-instructions.tsx +++ b/src/components/features/automations/create-instructions.tsx @@ -1,122 +1,112 @@ -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); + // 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; + }, + }, + ); + }; + + 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 0a4eb0bd..00000000 --- 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 15b28c2e..8f04afa7 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 6baee832..045c8318 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 */} - -