Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 92 additions & 4 deletions __tests__/components/automations/recommended-automations.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -37,6 +38,8 @@ vi.mock("react-i18next", () => ({
return key;
},
}),
// Minimal stub: render the key so components using <Trans> don't crash.
Trans: ({ i18nKey }: { i18nKey?: string }) => i18nKey ?? null,
}));

vi.mock("#/hooks/mutation/use-create-conversation", () => ({
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
Expand All @@ -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();
Expand All @@ -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);
Expand All @@ -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")
Expand All @@ -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(
Expand Down
46 changes: 46 additions & 0 deletions __tests__/utils/automation-responder.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
177 changes: 177 additions & 0 deletions src/components/features/automations/deployment-choice-modal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<a
href={OPENHANDS_SELF_HOSTED_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
data-testid="deployment-choice-description-self-hosted"
className="text-white hover:opacity-80"
>
{children}
</a>
);
}

/**
* 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(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could avoid calling useAutomationPreferencesStore here directly by passing a getter & setter (dontShowAgain and onDontShowAgainChange) as props from RecommendedAutomationsLauncher.

RecommendedAutomationsLauncher already reads from the store, so adding the setter selector there and passing both values down would keep the modal purely presentational. And tests should no longer need a global store reset in beforeEach.

deployment-choice-modal.tsx

// Remove the two useAutomationPreferencesStore calls and add explicit props
interface DeploymentChoiceModalProps {
  onContinueLocal: () => void;
  onClose: () => void;
  dontShowAgain: boolean;
  onDontShowAgainChange: (value: boolean) => void;
}

export function DeploymentChoiceModal({
  onContinueLocal,
  onClose,
  dontShowAgain,
  onDontShowAgainChange,
}: DeploymentChoiceModalProps) {
  // no store access here
  // replace `hideResponderDeploymentChoice` → `dontShowAgain`
  // replace `setHideResponderDeploymentChoice` → `onDontShowAgainChange`
}

recommended-automations-launcher.tsx

// Add the setter 
const setHideResponderDeploymentChoice = useAutomationPreferencesStore(
  (state) => state.setHideResponderDeploymentChoice,
);

// Pass both into the modal
{deploymentChoice && (
  <DeploymentChoiceModal
    onContinueLocal={() => {
      const automation = deploymentChoice;
      setDeploymentChoice(null);
      proceedWithLocalSetup(automation);
    }}
    onClose={() => setDeploymentChoice(null)}
    dontShowAgain={hideResponderDeploymentChoice}
    onDontShowAgainChange={setHideResponderDeploymentChoice}
  />
)}

(state) => state.hideResponderDeploymentChoice,
);
const setHideResponderDeploymentChoice = useAutomationPreferencesStore(
(state) => state.setHideResponderDeploymentChoice,
);

return (
<ModalBackdrop
onClose={onClose}
aria-label={t(I18nKey.DEPLOYMENT_CHOICE$TITLE)}
>
<div
data-testid="deployment-choice-modal"
className={cn(
"relative rounded-xl border border-[var(--oh-border)] bg-base-secondary",
modalWidthClassName("xl"),
MODAL_MAX_WIDTH_VIEWPORT,
)}
>
<ModalCloseButton
onClose={onClose}
testId="deployment-choice-modal-close"
/>

<header className="px-6 pb-2 pr-12 pt-6">
<h2
id="deployment-choice-modal-title"
className="text-lg font-medium text-white"
>
{t(I18nKey.DEPLOYMENT_CHOICE$TITLE)}
</h2>
<p className="mt-2 text-sm leading-relaxed text-tertiary-light">
<Trans
ns="openhands"
i18nKey={I18nKey.DEPLOYMENT_CHOICE$DESCRIPTION}
components={{ docs: <SelfHostDocsLink /> }}
/>
</p>
</header>

<div className="flex flex-col gap-4 px-6 pb-6 pt-2 sm:flex-row">
{/* Left: poll locally */}
<div className={CHOICE_CARD_CLASSNAME}>
<div className="flex items-center gap-2 text-white">
<Laptop size={18} className="shrink-0" />
<h3 className="text-sm font-medium">
{t(I18nKey.DEPLOYMENT_CHOICE$LOCAL_TITLE)}
</h3>
</div>
<p className="flex-1 text-xs leading-relaxed text-tertiary-light">
{t(I18nKey.DEPLOYMENT_CHOICE$LOCAL_DESCRIPTION)}
</p>
<BrandButton
type="button"
variant="secondary"
testId="deployment-choice-local"
className="w-full"
onClick={onContinueLocal}
>
{t(I18nKey.DEPLOYMENT_CHOICE$LOCAL_ACTION)}
</BrandButton>
</div>

{/* Right: OpenHands Cloud */}
<div className={CHOICE_CARD_CLASSNAME}>
<div className="flex items-center gap-2 text-white">
<Cloud size={18} className="shrink-0" />
<h3 className="text-sm font-medium">
{t(I18nKey.DEPLOYMENT_CHOICE$CLOUD_TITLE)}
</h3>
</div>
<p className="flex-1 text-xs leading-relaxed text-tertiary-light">
{t(I18nKey.DEPLOYMENT_CHOICE$CLOUD_DESCRIPTION)}
</p>
<a
href={OPENHANDS_CLOUD_INTEGRATIONS_URL}
target="_blank"
rel="noopener noreferrer"
data-testid="deployment-choice-cloud"
onClick={onClose}
className={cn(
formControlButtonClassName,
"w-full bg-white text-[#0d0f11] hover:opacity-90",
)}
>
<OpenHandsLogo
width={22}
height={15}
className="shrink-0 invert"
aria-hidden
/>
{t(I18nKey.DEPLOYMENT_CHOICE$CLOUD_ACTION)}
</a>
</div>
</div>

<footer className="flex items-center border-t border-[var(--oh-border)] px-6 py-3">
<label className="flex cursor-pointer items-center gap-2 text-xs text-tertiary-light">
<input
type="checkbox"
data-testid="deployment-choice-dont-show-again"
checked={hideResponderDeploymentChoice}
onChange={(event) =>
setHideResponderDeploymentChoice(event.target.checked)
}
/>
{t(I18nKey.DEPLOYMENT_CHOICE$DONT_SHOW_AGAIN)}
</label>
</footer>
</div>
</ModalBackdrop>
);
}
Loading
Loading