Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ export function RuntimeErrorTooltip({
side="bottom"
sideOffset={12}
>
<span className="leading-4">{detail}</span>
<span className="block max-h-48 overflow-y-auto overflow-x-hidden break-words whitespace-pre-line leading-4">
{detail}
</span>
</TooltipContent>
</Tooltip>
);
Expand Down
89 changes: 47 additions & 42 deletions desktop/src/features/onboarding/ui/SetupStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -463,19 +463,56 @@ function RuntimeAuthError({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
}

function RuntimeCard({
installError,
isInstalling,
onInstall,
installResults,
onInstallResultsChange,
runtime,
}: {
installError: string | null;
isInstalling: boolean;
onInstall: () => void;
installResults: InstallResultsState;
onInstallResultsChange: React.Dispatch<
React.SetStateAction<InstallResultsState>
>;
runtime: AcpRuntimeCatalogEntry;
}) {
// Each card owns its own mutation instance so concurrent installs on
// different cards each track their own isPending state and callbacks
// independently (react-query v5 per-mutate callbacks only fire for the
// latest mutate() call on a shared instance, silently dropping earlier ones).
const installMutation = useInstallAcpRuntimeMutation();
const installError = installResults[runtime.id]?.error ?? null;
const isInstalling = installMutation.isPending;
const isAvailable = runtime.availability === "available";
const isReady = runtimeIsReadyForOnboarding(runtime);

function handleInstall() {
onInstallResultsChange((current) => ({
...current,
[runtime.id]: { error: null, success: false },
}));

installMutation.mutate(runtime.id, {
onSuccess: (result) => {
onInstallResultsChange((current) => ({
...current,
[runtime.id]: result.success
? { error: null, success: true }
: {
error: getInstallErrorMessage(result.steps),
success: false,
},
}));
},
onError: (error) => {
onInstallResultsChange((current) => ({
...current,
[runtime.id]: {
error: error instanceof Error ? error.message : "Install failed.",
success: false,
},
}));
},
});
}

return (
<Card
className={cn(
Expand All @@ -499,7 +536,7 @@ function RuntimeCard({
<RuntimeStatus
installError={installError}
isInstalling={isInstalling}
onInstall={onInstall}
onInstall={handleInstall}
runtime={runtime}
/>
{!isAvailable && runtimeDetailText(runtime) ? (
Expand All @@ -517,7 +554,7 @@ function RuntimeCard({
{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="Installation couldn’t be completed. Try again."
detail={installError}
label="Installation failed"
showIcon
testId={`onboarding-runtime-error-${runtime.id}`}
Expand Down Expand Up @@ -560,34 +597,6 @@ function RuntimeProvidersSection({
}) {
const { errorMessage, isChecking, items } = runtimeProviders;
const orderedItems = getVisibleOnboardingRuntimes(items);
const installMutation = useInstallAcpRuntimeMutation();

function handleInstall(runtimeId: string) {
onInstallResultsChange((current) => ({
...current,
[runtimeId]: { error: null, success: false },
}));

installMutation.mutate(runtimeId, {
onSuccess: (result) => {
onInstallResultsChange((current) => ({
...current,
[runtimeId]: result.success
? { error: null, success: true }
: { error: getInstallErrorMessage(result.steps), success: false },
}));
},
onError: (error) => {
onInstallResultsChange((current) => ({
...current,
[runtimeId]: {
error: error instanceof Error ? error.message : "Install failed.",
success: false,
},
}));
},
});
}

return (
<section className="flex min-h-full w-full flex-col items-center">
Expand All @@ -606,13 +615,9 @@ function RuntimeProvidersSection({
<div className="grid min-w-0 w-full max-w-[592px] grid-cols-1 gap-4 md:grid-cols-2">
{orderedItems.map((runtime) => (
<RuntimeCard
installError={installResults[runtime.id]?.error ?? null}
isInstalling={
installMutation.isPending &&
installMutation.variables === runtime.id
}
installResults={installResults}
key={runtime.id}
onInstall={() => handleInstall(runtime.id)}
onInstallResultsChange={onInstallResultsChange}
runtime={runtime}
/>
))}
Expand Down
122 changes: 53 additions & 69 deletions desktop/src/features/settings/ui/DoctorSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,22 +279,56 @@ function RuntimeHeader({
}

function RuntimeRow({
installError,
installSuccess,
isInstalling,
onInstall,
resetEpoch,
runtime,
}: {
installError: string | null;
installSuccess: boolean;
isInstalling: boolean;
onInstall: () => void;
resetEpoch: number;
runtime: AcpRuntimeCatalogEntry;
}) {
const [terminalLaunchMethodId, setTerminalLaunchMethodId] = React.useState<
string | null
>(null);
const [isUpdateWarningOpen, setIsUpdateWarningOpen] = React.useState(false);
// Each row owns its mutation instance so concurrent installs each track
// their own isPending / result state independently.
const installMutation = useInstallAcpRuntimeMutation();
const [installResult, setInstallResult] = React.useState<{
success: boolean;
error: string | null;
} | null>(null);
// Clear stale install results when the parent triggers a catalog refresh
// (Check again) — the runtime may now be healthy and stale failure state
// would linger because keyed rows don't remount on refetch.
// biome-ignore lint/correctness/useExhaustiveDependencies: resetEpoch is an intentional trigger only; its value is not consumed in the effect body
React.useEffect(() => {
setInstallResult(null);
}, [resetEpoch]);
const isInstalling = installMutation.isPending;
const installError = installResult?.error ?? null;
const installSuccess = installResult?.success ?? false;

function handleInstall() {
setInstallResult(null);
installMutation.mutate(runtime.id, {
onSuccess: (result) => {
if (result.success) {
setInstallResult({ success: true, error: null });
} else {
setInstallResult({
success: false,
error: getInstallErrorMessage(result.steps),
});
}
},
onError: (error) => {
setInstallResult({
success: false,
error: error instanceof Error ? error.message : "Install failed.",
});
},
});
}

const canConnectAccount =
runtime.availability === "available" &&
runtime.authStatus.status === "logged_out";
Expand Down Expand Up @@ -352,7 +386,7 @@ function RuntimeRow({
setIsUpdateWarningOpen(true);
return;
}
onInstall();
handleInstall();
}}
runtime={runtime}
/>
Expand All @@ -372,7 +406,10 @@ function RuntimeRow({
</p>
) : null}
{installError ? (
<p className="mt-2 whitespace-pre-line rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-1.5 text-sm text-destructive">
<p
className="mt-2 whitespace-pre-line rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-1.5 text-sm text-destructive"
data-testid={`doctor-runtime-install-error-${runtime.id}`}
>
{installError}
</p>
) : null}
Expand Down Expand Up @@ -410,7 +447,7 @@ function RuntimeRow({
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onInstall}
onClick={handleInstall}
data-testid={`doctor-runtime-confirm-update-${runtime.id}`}
>
Update
Expand Down Expand Up @@ -491,59 +528,9 @@ export function DoctorSettingsPanel() {
[runtimesQuery.data],
);
const isRefreshing = runtimesQuery.isFetching;
const installMutation = useInstallAcpRuntimeMutation();
const [installResults, setInstallResults] = React.useState<
Record<string, { success: boolean; error: string | null }>
>({});
// Per-runtime installing state: tracks which runtime IDs have an in-flight
// install so concurrent installs each show their own spinner correctly.
const [installingIds, setInstallingIds] = React.useState<Set<string>>(
new Set(),
);

function handleInstall(runtimeId: string) {
// Clear any previous result for this runtime before retrying.
setInstallResults((prev) => ({
...prev,
[runtimeId]: { success: false, error: null },
}));
setInstallingIds((prev) => new Set(prev).add(runtimeId));

installMutation.mutate(runtimeId, {
onSuccess: (result) => {
if (result.success) {
setInstallResults((prev) => ({
...prev,
[runtimeId]: { success: true, error: null },
}));
} else {
setInstallResults((prev) => ({
...prev,
[runtimeId]: {
success: false,
error: getInstallErrorMessage(result.steps),
},
}));
}
},
onError: (error) => {
setInstallResults((prev) => ({
...prev,
[runtimeId]: {
success: false,
error: error instanceof Error ? error.message : "Install failed.",
},
}));
},
onSettled: () => {
setInstallingIds((prev) => {
const next = new Set(prev);
next.delete(runtimeId);
return next;
});
},
});
}
// Incremented each time the user clicks "Check again" so RuntimeRow
// useEffect clears stale install results from before the refresh.
const [resetEpoch, setResetEpoch] = React.useState(0);

return (
<section
Expand All @@ -558,7 +545,7 @@ export function DoctorSettingsPanel() {
<Button
disabled={isRefreshing}
onClick={() => {
setInstallResults({});
setResetEpoch((e) => e + 1);
void runtimesQuery.refetch();
void gitBashQuery.refetch();
}}
Expand Down Expand Up @@ -598,11 +585,8 @@ export function DoctorSettingsPanel() {
<div className="space-y-3" data-testid="doctor-runtime-list">
{runtimes.map((runtime) => (
<RuntimeRow
installError={installResults[runtime.id]?.error ?? null}
installSuccess={installResults[runtime.id]?.success ?? false}
isInstalling={installingIds.has(runtime.id)}
key={runtime.id}
onInstall={() => handleInstall(runtime.id)}
resetEpoch={resetEpoch}
runtime={runtime}
/>
))}
Expand Down
44 changes: 44 additions & 0 deletions desktop/src/shared/lib/installError.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,47 @@ test("getInstallErrorMessage: failed step with empty stderr falls back to stdout
]);
assert.match(message, /some stdout output/);
});

test("getInstallErrorMessage: hint and step detail are separated by double newline for whitespace-pre-line rendering", () => {
const hint = "Git Bash is required. Install it from git-scm.com.";
const message = getInstallErrorMessage([
{
step: "shell",
command: "bash -l -c 'npm install'",
success: false,
stdout: "",
stderr: "bash: command not found",
exitCode: 127,
hint,
},
]);
assert.ok(
message.includes("\n\n"),
"hint and step detail should be separated by a blank line",
);
assert.ok(message.startsWith(hint));
});

test("getInstallErrorMessage: only reports the last (failing) step when multiple steps present", () => {
const message = getInstallErrorMessage([
{
step: "node",
command: "node --version",
success: true,
stdout: "v20.0.0",
stderr: "",
exitCode: 0,
},
{
step: "adapter",
command: "npm install -g @agentclientprotocol/claude-code-acp",
success: false,
stdout: "",
stderr: "npm ERR! code E404",
exitCode: 1,
},
]);
assert.match(message, /Step "adapter" failed:/);
assert.match(message, /npm ERR! code E404/);
assert.doesNotMatch(message, /Step "node"/);
});
Loading
Loading