diff --git a/desktop/src/features/onboarding/ui/RuntimeErrorTooltip.tsx b/desktop/src/features/onboarding/ui/RuntimeErrorTooltip.tsx new file mode 100644 index 0000000000..15708d3087 --- /dev/null +++ b/desktop/src/features/onboarding/ui/RuntimeErrorTooltip.tsx @@ -0,0 +1,49 @@ +import { AlertTriangle } from "lucide-react"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; + +type RuntimeErrorTooltipProps = { + className?: string; + detail: string; + label: string; + showIcon?: boolean; + testId?: string; +}; + +export function RuntimeErrorTooltip({ + className, + detail, + label, + showIcon = false, + testId, +}: RuntimeErrorTooltipProps) { + return ( + + + + {showIcon ? ( + + + + {detail} + + + ); +} diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 9056e6486d..27f4f4bfce 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -23,6 +23,7 @@ import { runtimeCanBeSelected, } from "./onboardingRuntimeSelection"; import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome"; +import { RuntimeErrorTooltip } from "./RuntimeErrorTooltip"; import { OnboardingFooter } from "./OnboardingFooter"; import { getRuntimeDisplayLabel, RuntimeIcon } from "./RuntimeIcon"; import { @@ -153,6 +154,7 @@ function RuntimeStatus({ runtime.authStatus.status === "logged_out", }); const connectMutation = useConnectAcpRuntimeMutation(); + const runtimesQuery = useAcpRuntimesQuery(); const authMethods = getOnboardingAuthMethods( runtime, methodsQuery.data?.methods ?? [], @@ -197,14 +199,18 @@ function RuntimeStatus({ SET UP {methodsQuery.error instanceof Error ? ( - - Couldn’t load sign-in options. - + ) : null} {connectMutation.error instanceof Error ? ( - - {connectMutation.error.message} - + ) : null} ); @@ -223,11 +229,44 @@ function RuntimeStatus({ ); } - if (installError) { + if ( + runtime.availability === "available" && + runtime.authStatus.status === "unknown" + ) { return ( -
- -
+ + ); + } + + if (installError && runtime.canAutoInstall) { + return ( + ); } @@ -402,7 +441,7 @@ function runtimeDetailText(runtime: AcpRuntimeCatalogEntry): string { if (runtime.availability === "cli_missing") { return "ACP adapter detected; CLI missing."; } - return "Not installed yet."; + return ""; } function isSupportedOnboardingAuthMethod( @@ -452,36 +491,26 @@ function getOnboardingAuthMethods( return supported; } -function RuntimeAuthActions({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { - const runtimesQuery = useAcpRuntimesQuery(); - +function RuntimeAuthError({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { if (runtime.authStatus.status === "config_invalid") { return ( -

- {runtime.authStatus.diagnostic} -

+ ); } - if (runtime.authStatus.status === "unknown") { + if ( + runtime.availability === "available" && + runtime.authStatus.status === "unknown" + ) { return ( -
- - Couldn’t verify authentication. - - -
+ ); } return null; @@ -552,18 +581,29 @@ function RuntimeCard({ runtime={runtime} setupFlashToken={setupFlashToken} /> - {!isAvailable && !installError ? ( -

+ {!isAvailable && runtimeDetailText(runtime) ? ( +

{runtimeDetailText(runtime)}

) : null} - {installError ? ( -

- {installError} -

- ) : null} - + {installError ? ( + + ) : ( + + )} ); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index de9190f284..83c84a40c7 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -128,6 +128,7 @@ type E2eConfig = { acpRuntimesCatalog?: RawAcpRuntimeCatalogEntry[]; acpRuntimesDelayMs?: number; acpAuthMethods?: Record; + acpAuthMethodsError?: string; connectAcpRuntimeResult?: RawConnectAcpRuntimeResult; connectAcpRuntimeDelayMs?: number; connectAcpRuntimeError?: string; @@ -6699,6 +6700,10 @@ async function handleDiscoverAcpAuthMethods( args: { runtimeId?: string }, config: E2eConfig | undefined, ): Promise { + const error = config?.mock?.acpAuthMethodsError; + if (error) { + throw new Error(error); + } const runtimeId = args.runtimeId ?? ""; const configured = config?.mock?.acpAuthMethods?.[runtimeId]; if (configured) { diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index 7c2f3aabf9..6159fd64f3 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -636,13 +636,35 @@ for (const authStatus of [ await setupButton.click(); await expect(card).toHaveAttribute("aria-checked", "true"); } else if (authStatus.status === "unknown") { + const error = card.getByRole("status", { + name: /Status unavailable/, + }); + await expect(error).toBeVisible(); + await expect(error).toHaveCSS("font-size", "12px"); + await expect(error).toHaveCSS("position", "absolute"); + await error.hover(); + await expect(page.getByRole("tooltip")).toHaveText( + "Couldn’t verify authentication.", + ); await expect( - card.getByText("Couldn’t verify authentication"), - ).toBeVisible(); + card.getByRole("button", { name: "Check Claude again" }), + ).toHaveText("CHECK AGAIN"); await card.click(); await expect(card).toHaveAttribute("aria-checked", "true"); } else { - await expect(card.getByText("Fix Claude config")).toBeVisible(); + const error = card.getByRole("status", { + name: /Configuration invalid/, + }); + await expect(error).toBeVisible(); + await expect(error).toHaveCSS("font-size", "12px"); + await expect(error).toHaveCSS("position", "absolute"); + await error.hover(); + await expect(page.getByRole("tooltip")).toHaveText( + "Check this runtime’s configuration and try again.", + ); + await expect(page.getByRole("tooltip")).not.toContainText( + "Fix Claude config", + ); await card.click(); await expect(card).toHaveAttribute("aria-checked", "true"); } @@ -678,6 +700,199 @@ test("setup cards only show checks after user selection", async ({ page }) => { ).toHaveText("INSTALLED"); }); +test("unavailable sign-in options use the compact error and tooltip pattern", async ({ + page, +}) => { + const discoveryError = "Auth discovery failed with sensitive details"; + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + availableRuntime("claude", { status: "logged_out" }), + ], + acpAuthMethodsError: discoveryError, + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + + const card = page.getByTestId("onboarding-runtime-claude"); + const setupButton = card.getByTestId( + "onboarding-runtime-instructions-claude", + ); + const error = card.getByRole("status", { name: /Sign-in unavailable/ }); + await expect(error).toBeVisible(); + await expect(error).toHaveCSS("font-size", "12px"); + await expect(error).toHaveCSS("position", "absolute"); + await expect(error).toHaveCSS("white-space", "nowrap"); + await expect(error).not.toHaveAttribute("title"); + await error.hover(); + await expect(page.getByRole("tooltip")).toHaveText( + "Couldn’t load sign-in options.", + ); + await expect(page.getByRole("tooltip")).not.toContainText(discoveryError); + await expect(setupButton).toBeVisible(); + await expect(setupButton).toHaveText("SET UP"); +}); + +test("failed sign-in uses the compact error and tooltip pattern", async ({ + page, +}) => { + const connectionError = "Terminal launch failed with sensitive details"; + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + availableRuntime("claude", { status: "logged_out" }), + ], + acpAuthMethods: { + claude: { + methods: [ + { + id: "login", + name: "Sign in", + description: null, + type: "terminal", + }, + ], + }, + }, + connectAcpRuntimeError: connectionError, + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + + const card = page.getByTestId("onboarding-runtime-claude"); + const setupButton = card.getByTestId( + "onboarding-runtime-instructions-claude", + ); + const heading = card.getByRole("heading", { name: "Claude" }); + const headingTopBefore = await heading.evaluate( + (element) => element.getBoundingClientRect().top, + ); + const setupTopBefore = await setupButton.evaluate( + (element) => element.getBoundingClientRect().top, + ); + + await setupButton.click(); + + const error = card.getByRole("status", { name: /Sign-in failed/ }); + await expect(error).toBeVisible(); + await expect(error).toHaveCSS("font-size", "12px"); + await expect(error).toHaveCSS("position", "absolute"); + await expect(error).toHaveCSS("white-space", "nowrap"); + await expect(error).not.toHaveAttribute("title"); + await error.hover(); + await expect(page.getByRole("tooltip")).toHaveText( + "Couldn’t start sign-in. Try again.", + ); + await expect(page.getByRole("tooltip")).not.toContainText(connectionError); + await page.keyboard.press("Escape"); + await expect(page.getByRole("tooltip")).toBeHidden(); + await error.focus(); + const tooltip = page.getByRole("tooltip"); + await expect(tooltip).toHaveText("Couldn’t start sign-in. Try again."); + await expect(error).toHaveAccessibleName( + "Sign-in failed. Couldn’t start sign-in. Try again.", + ); + const [cardBox, setupBox, tooltipBox] = await Promise.all([ + card.boundingBox(), + setupButton.boundingBox(), + tooltip.boundingBox(), + ]); + expect(cardBox).not.toBeNull(); + expect(setupBox).not.toBeNull(); + expect(tooltipBox).not.toBeNull(); + expect(tooltipBox?.y).toBeGreaterThanOrEqual( + (cardBox?.y ?? 0) + (cardBox?.height ?? 0), + ); + expect(tooltipBox?.y).toBeGreaterThanOrEqual( + (setupBox?.y ?? 0) + (setupBox?.height ?? 0), + ); + await expect(setupButton).toBeVisible(); + expect( + await heading.evaluate((element) => element.getBoundingClientRect().top), + ).toBe(headingTopBefore); + expect( + await setupButton.evaluate( + (element) => element.getBoundingClientRect().top, + ), + ).toBe(setupTopBefore); +}); + +test("failed install pins a single-line 12px error without moving card content", async ({ + page, +}) => { + const installError = "Install already in progress with additional details"; + await installMockBridge( + page, + { + installAcpRuntimeResult: { + success: false, + steps: [ + { + step: "Install adapter", + command: "npm install adapter", + success: false, + stdout: "", + stderr: installError, + exit_code: 1, + }, + ], + }, + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + + const card = page.getByTestId("onboarding-runtime-claude"); + const setupButton = page.getByTestId("onboarding-runtime-install-claude"); + const heading = card.getByRole("heading", { name: "Claude" }); + const headingTopBefore = await heading.evaluate( + (element) => element.getBoundingClientRect().top, + ); + const detail = card.getByText("CLI detected; ACP adapter missing."); + const detailTopBefore = await detail.evaluate( + (element) => element.getBoundingClientRect().top, + ); + const setupTopBefore = await setupButton.evaluate( + (element) => element.getBoundingClientRect().top, + ); + + await setupButton.click(); + + const error = page.getByTestId("onboarding-runtime-error-claude"); + await expect(error).toBeVisible(); + await expect(error).toHaveText(/Setup failed/); + await expect(error).not.toHaveAttribute("title"); + await expect(setupButton).toBeVisible(); + await expect(setupButton).toHaveText("SET UP"); + await expect(error).toHaveCSS("font-size", "12px"); + await expect(error).toHaveCSS("position", "absolute"); + await expect(error).toHaveCSS("white-space", "nowrap"); + await expect(error.locator("span")).toHaveCSS("text-overflow", "ellipsis"); + await error.hover(); + await expect(page.getByRole("tooltip")).toHaveText( + "Setup couldn’t be completed. Try again.", + ); + await expect(page.getByRole("tooltip")).not.toContainText(installError); + expect( + await heading.evaluate((element) => element.getBoundingClientRect().top), + ).toBe(headingTopBefore); + expect( + await detail.evaluate((element) => element.getBoundingClientRect().top), + ).toBe(detailTopBefore); + expect( + await setupButton.evaluate( + (element) => element.getBoundingClientRect().top, + ), + ).toBe(setupTopBefore); +}); + test("successful install still waits for refreshed runtime readiness", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 4c91b10ccc..f4fe926817 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -130,6 +130,7 @@ type MockBridgeOptions = { acpRuntimesCatalog?: Record[]; acpRuntimesDelayMs?: number; acpAuthMethods?: Record[] }>; + acpAuthMethodsError?: string; connectAcpRuntimeResult?: { launched: boolean }; connectAcpRuntimeDelayMs?: number; connectAcpRuntimeError?: string;