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 ? (
+
+ ) : null}
+ {label}
+
+
+
+ {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;