Polish onboarding runtime error states (#2116)

Signed-off-by: npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 <29b529ff43be424db89f51bff58a38cf86942acbffff98a14ce50b38c2a57d8f@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 <29b529ff43be424db89f51bff58a38cf86942acbffff98a14ce50b38c2a57d8f@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
cynfria
2026-07-19 16:15:39 +00:00
committed by GitHub
co-authored by npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 Wes Pinky
parent 820d023305
commit 31090b186f
5 changed files with 357 additions and 47 deletions
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label={`${label}. ${detail}`}
className={className}
data-testid={testId}
role="status"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Focus exposes the error tooltip to keyboard users without adding a nested card action
tabIndex={0}
>
{showIcon ? (
<AlertTriangle
aria-hidden="true"
className="h-3.5 w-3.5 shrink-0"
/>
) : null}
<span className="min-w-0 truncate">{label}</span>
</span>
</TooltipTrigger>
<TooltipContent
className="max-w-80 text-left"
side="bottom"
sideOffset={12}
>
<span className="leading-4">{detail}</span>
</TooltipContent>
</Tooltip>
);
}
@@ -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
</Button>
{methodsQuery.error instanceof Error ? (
<span className="text-2xs text-destructive">
Couldnt load sign-in options.
</span>
<RuntimeErrorTooltip
className="absolute inset-x-3 bottom-2 truncate text-xs leading-4 text-destructive"
detail="Couldnt load sign-in options."
label="Sign-in unavailable"
/>
) : null}
{connectMutation.error instanceof Error ? (
<span className="text-2xs text-destructive">
{connectMutation.error.message}
</span>
<RuntimeErrorTooltip
className="absolute inset-x-3 bottom-2 truncate text-xs leading-4 text-destructive"
detail="Couldnt start sign-in. Try again."
label="Sign-in failed"
/>
) : null}
</div>
);
@@ -223,11 +229,44 @@ function RuntimeStatus({
);
}
if (installError) {
if (
runtime.availability === "available" &&
runtime.authStatus.status === "unknown"
) {
return (
<div className="flex h-8 w-8 items-center justify-center">
<AlertTriangle className="h-4 w-4 text-destructive" />
</div>
<Button
aria-label={`Check ${runtime.label} again`}
className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40"
disabled={runtimesQuery.isFetching}
onClick={(event) => {
event.stopPropagation();
void runtimesQuery.refetch();
}}
type="button"
variant="ghost"
>
{runtimesQuery.isFetching ? "CHECKING…" : "CHECK AGAIN"}
</Button>
);
}
if (installError && runtime.canAutoInstall) {
return (
<Button
aria-label={`Retry ${runtime.label} setup`}
className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40"
data-setup-flash={isSetupFlashing ? "true" : undefined}
data-testid={`onboarding-runtime-install-${runtime.id}`}
onClick={(event) => {
event.stopPropagation();
onSelect();
onInstall();
}}
type="button"
variant="ghost"
>
SET UP
</Button>
);
}
@@ -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 (
<p className="mt-2 text-2xs leading-4 text-destructive">
{runtime.authStatus.diagnostic}
</p>
<RuntimeErrorTooltip
className="absolute inset-x-3 bottom-2 truncate text-xs leading-4 text-destructive"
detail="Check this runtimes configuration and try again."
label="Configuration invalid"
/>
);
}
if (runtime.authStatus.status === "unknown") {
if (
runtime.availability === "available" &&
runtime.authStatus.status === "unknown"
) {
return (
<div className="mt-2 flex flex-col items-center gap-1.5">
<span className="text-2xs text-muted-foreground">
Couldnt verify authentication.
</span>
<Button
disabled={runtimesQuery.isFetching}
className="h-6 rounded-full px-2 text-2xs"
onClick={(event) => {
event.stopPropagation();
void runtimesQuery.refetch();
}}
size="sm"
type="button"
variant="ghost"
>
{runtimesQuery.isFetching ? "Checking…" : "Check again"}
</Button>
</div>
<RuntimeErrorTooltip
className="absolute inset-x-3 bottom-2 truncate text-xs leading-4 text-destructive"
detail="Couldnt verify authentication."
label="Status unavailable"
/>
);
}
return null;
@@ -552,18 +581,29 @@ function RuntimeCard({
runtime={runtime}
setupFlashToken={setupFlashToken}
/>
{!isAvailable && !installError ? (
<p className="max-w-[13rem] text-2xs leading-4 text-muted-foreground">
{!isAvailable && runtimeDetailText(runtime) ? (
<p
aria-hidden={installError ? "true" : undefined}
className={cn(
"max-w-[13rem] text-2xs leading-4 text-muted-foreground",
installError && "invisible",
)}
>
{runtimeDetailText(runtime)}
</p>
) : null}
{installError ? (
<p className="max-w-[13rem] text-2xs leading-4 text-destructive">
{installError}
</p>
) : null}
<RuntimeAuthActions runtime={runtime} />
</div>
{installError ? (
<RuntimeErrorTooltip
className="absolute inset-x-3 bottom-2 flex min-w-0 items-center justify-center gap-1.5 overflow-hidden whitespace-nowrap text-xs leading-4 text-destructive"
detail="Setup couldnt be completed. Try again."
label="Setup failed"
showIcon
testId={`onboarding-runtime-error-${runtime.id}`}
/>
) : (
<RuntimeAuthError runtime={runtime} />
)}
</div>
);
}
+5
View File
@@ -128,6 +128,7 @@ type E2eConfig = {
acpRuntimesCatalog?: RawAcpRuntimeCatalogEntry[];
acpRuntimesDelayMs?: number;
acpAuthMethods?: Record<string, RawAcpAuthMethodsResult>;
acpAuthMethodsError?: string;
connectAcpRuntimeResult?: RawConnectAcpRuntimeResult;
connectAcpRuntimeDelayMs?: number;
connectAcpRuntimeError?: string;
@@ -6699,6 +6700,10 @@ async function handleDiscoverAcpAuthMethods(
args: { runtimeId?: string },
config: E2eConfig | undefined,
): Promise<RawAcpAuthMethodsResult> {
const error = config?.mock?.acpAuthMethodsError;
if (error) {
throw new Error(error);
}
const runtimeId = args.runtimeId ?? "";
const configured = config?.mock?.acpAuthMethods?.[runtimeId];
if (configured) {
@@ -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(
"Couldnt verify authentication.",
);
await expect(
card.getByText("Couldnt 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 runtimes 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(
"Couldnt 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(
"Couldnt 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("Couldnt start sign-in. Try again.");
await expect(error).toHaveAccessibleName(
"Sign-in failed. Couldnt 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 couldnt 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,
}) => {
+1
View File
@@ -130,6 +130,7 @@ type MockBridgeOptions = {
acpRuntimesCatalog?: Record<string, unknown>[];
acpRuntimesDelayMs?: number;
acpAuthMethods?: Record<string, { methods: Record<string, unknown>[] }>;
acpAuthMethodsError?: string;
connectAcpRuntimeResult?: { launched: boolean };
connectAcpRuntimeDelayMs?: number;
connectAcpRuntimeError?: string;