diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index 16b89fce7e..dda7857260 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -113,7 +113,7 @@ pub(crate) enum RequirementPayload { /// One-line stderr excerpt identifying the parse error. diagnostic: String, }, - /// Git for Windows is missing; open Doctor for the installation guide. + /// Git for Windows is missing; open Agent runtimes for the installation guide. GitBash, } @@ -139,7 +139,7 @@ impl RequirementPayload { .map(String::as_str) .unwrap_or("the agent"); format!( - "install the {} ACP adapter (open Doctor in Settings to diagnose)", + "install the {} ACP adapter (open Agent runtimes in Settings to diagnose)", harness ) } @@ -149,7 +149,7 @@ impl RequirementPayload { .map(String::as_str) .unwrap_or("the agent"); format!( - "reinstall the {} ACP adapter — the installed version is outdated (open Doctor in Settings to diagnose)", + "reinstall the {} ACP adapter — the installed version is outdated (open Agent runtimes in Settings to diagnose)", harness ) } @@ -159,7 +159,7 @@ impl RequirementPayload { .map(String::as_str) .unwrap_or("the agent"); format!( - "install {} CLI (open Doctor in Settings to diagnose)", + "install {} CLI (open Agent runtimes in Settings to diagnose)", harness ) } @@ -168,7 +168,10 @@ impl RequirementPayload { .first() .map(String::as_str) .unwrap_or("the agent"); - format!("install {} (open Doctor in Settings to diagnose)", harness) + format!( + "install {} (open Agent runtimes in Settings to diagnose)", + harness + ) } }, RequirementPayload::CliConfigInvalid { @@ -184,7 +187,7 @@ impl RequirementPayload { ) } RequirementPayload::GitBash => { - "install Git for Windows (open Doctor in Settings to diagnose)".to_string() + "install Git for Windows (open Agent runtimes in Settings to diagnose)".to_string() } } } @@ -264,7 +267,7 @@ impl SetupPayload { .any(|r| matches!(r, RequirementPayload::CliConfigInvalid { .. })); let footer = if has_doctor_requirement { - "Open Doctor in the Buzz app, install Git for Windows, then re-check and restart the agent.".to_string() + "Open Agent runtimes in Settings, install Git for Windows, then re-check and restart the agent.".to_string() } else if all_external { // All requirements are external config files — Edit Agent cannot // help. Don't send the user there. @@ -743,6 +746,53 @@ mod tests { ); } + #[test] + fn nudge_body_runtime_install_copy_points_to_agent_runtimes() { + for availability in [ + AcpAvailabilityStatus::AdapterMissing, + AcpAvailabilityStatus::AdapterOutdated, + AcpAvailabilityStatus::CliMissing, + AcpAvailabilityStatus::NotInstalled, + ] { + let payload = SetupPayload { + agent_name: "Codex".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![RequirementPayload::CliLogin { + probe_args: vec!["codex".to_string()], + setup_copy: "run `codex login`".to_string(), + availability, + }], + }; + let body = payload.nudge_body(); + assert!( + body.contains("Agent runtimes in Settings"), + "runtime install nudge must point to Agent runtimes; got: {body:?}" + ); + assert!( + !body.contains("Doctor"), + "runtime install nudge must not point to the removed Doctor section; got: {body:?}" + ); + } + } + + #[test] + fn nudge_body_git_bash_copy_points_to_agent_runtimes() { + let payload = SetupPayload { + agent_name: "Buzz Agent".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![RequirementPayload::GitBash], + }; + let body = payload.nudge_body(); + assert!( + body.contains("Open Agent runtimes in Settings"), + "Git Bash nudge must point to Agent runtimes; got: {body:?}" + ); + assert!( + !body.contains("Doctor"), + "Git Bash nudge must not point to the removed Doctor section; got: {body:?}" + ); + } + #[test] fn nudge_body_empty_requirements_falls_back_to_generic() { let payload = SetupPayload { diff --git a/desktop/public/runtime-icons/claude.png b/desktop/public/runtime-icons/claude.png new file mode 100644 index 0000000000..26f2160f5d Binary files /dev/null and b/desktop/public/runtime-icons/claude.png differ diff --git a/desktop/public/runtime-icons/codex.png b/desktop/public/runtime-icons/codex.png new file mode 100644 index 0000000000..fbdd19c060 Binary files /dev/null and b/desktop/public/runtime-icons/codex.png differ diff --git a/desktop/public/runtime-icons/goose.svg b/desktop/public/runtime-icons/goose.svg new file mode 100644 index 0000000000..c5ed5e00a8 --- /dev/null +++ b/desktop/public/runtime-icons/goose.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index 8da3bfc281..4a64de0cb2 100644 --- a/desktop/src/app/AppShellContext.tsx +++ b/desktop/src/app/AppShellContext.tsx @@ -49,7 +49,7 @@ type AppShellContextValue = { feedItemState: FeedItemState; // Open the Settings panel at the given section. Available on all surfaces // that render under AppShell (channel, home, projects, pulse, agents). - // Used by config-nudge cards to deep-link to Settings → Doctor. + // Used by config-nudge cards to deep-link to Settings → Agents. onOpenSettings: ((section: SettingsSection) => void) | null; }; diff --git a/desktop/src/app/routes/settings.tsx b/desktop/src/app/routes/settings.tsx index b3812ba511..0011018830 100644 --- a/desktop/src/app/routes/settings.tsx +++ b/desktop/src/app/routes/settings.tsx @@ -12,6 +12,10 @@ type SettingsRouteSearch = { function validateSettingsSearch( search: Record, ): SettingsRouteSearch { + if (search.section === "doctor") { + return { section: "agents" }; + } + return { section: isSettingsSection(search.section) ? search.section : undefined, }; diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index b15fc3fe6f..6a556f837d 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -624,7 +624,7 @@ export function AgentDefinitionDialog({ : selectedRuntime.availability === "cli_missing" ? `${selectedRuntime.label} ACP adapter is installed but the CLI is missing.` : `${selectedRuntime.label} is not installed.`}{" "} - Visit Settings > Doctor to set it up. + Visit Settings > Agents to set it up.

) : null; const advancedFieldsTransition = shouldReduceMotion diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 27f4f4bfce..25a37136a3 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -815,8 +815,8 @@ function RuntimeProvidersSection({ className="max-w-[560px] rounded-2xl bg-white/70 px-6 py-6 text-sm text-muted-foreground" data-testid="onboarding-acp-empty" > - No compatible ACP runtimes detected yet. You can finish setup now - and come back later in Settings > Doctor. + No compatible agent runtimes detected yet. You can finish setup now + and come back later in Settings > Agents.

)} diff --git a/desktop/src/features/profile/ui/ProfileAvatar.tsx b/desktop/src/features/profile/ui/ProfileAvatar.tsx index 899cf4ff9f..3a56ee329e 100644 --- a/desktop/src/features/profile/ui/ProfileAvatar.tsx +++ b/desktop/src/features/profile/ui/ProfileAvatar.tsx @@ -13,6 +13,7 @@ type ProfileAvatarProps = { label: string; className?: string; iconClassName?: string; + imageClassName?: string; plain?: boolean; testId?: string; }; @@ -23,6 +24,7 @@ export function ProfileAvatar({ label, className, iconClassName, + imageClassName, plain = false, testId, }: ProfileAvatarProps) { @@ -67,7 +69,7 @@ export function ProfileAvatar({ {src !== undefined ? ( { if (status === "error") setFailedSrc(liveSrc); diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index baae562cd4..cee156ece7 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -1,13 +1,5 @@ import * as React from "react"; -import { - AlertTriangle, - CheckCircle2, - Circle, - Download, - ExternalLink, - RefreshCw, - XCircle, -} from "lucide-react"; +import { EllipsisVertical, ExternalLink, RefreshCw } from "lucide-react"; import { openUrl } from "@tauri-apps/plugin-opener"; import { @@ -17,292 +9,272 @@ import { useGitBashPrerequisiteQuery, useInstallAcpRuntimeMutation, } from "@/features/agents/hooks"; -import { describeResolvedCommand } from "@/features/agents/ui/agentUi"; -import type { - AcpAuthMethod, - AcpRuntimeCatalogEntry, - AuthStatus, -} from "@/shared/api/types"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import type { AcpAuthMethod, AcpRuntimeCatalogEntry } from "@/shared/api/types"; import { getInstallErrorMessage } from "@/shared/lib/installError"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; -import { SettingsOptionGroup } from "./SettingsOptionGroup"; -import { SettingsSectionHeader } from "./SettingsSectionHeader"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { SectionHeader } from "@/shared/ui/PageHeader"; +import { Spinner } from "@/shared/ui/spinner"; +import { Switch } from "@/shared/ui/switch"; -function StatusIcon({ - availability, -}: { - availability: AcpRuntimeCatalogEntry["availability"]; -}) { - switch (availability) { - case "available": - return ; - case "adapter_missing": - return ; - case "adapter_outdated": - return ; - case "cli_missing": - return ; - case "not_installed": - return ; - } -} +const RUNTIME_LOGO_URLS: Record = { + "buzz-agent": "/app-icon@2x.png", + claude: "/runtime-icons/claude.png", + codex: "/runtime-icons/codex.png", + goose: "/runtime-icons/goose.svg", +}; -function AuthStatusBadge({ authStatus }: { authStatus: AuthStatus }) { - switch (authStatus.status) { - case "logged_in": - return ( - - - Authenticated - - ); - case "logged_out": - return ( - - - Not authenticated - - ); - case "config_invalid": - return ( - - - Config error - - ); - case "not_applicable": - case "unknown": - return null; - } -} +const RUNTIME_LOGO_SCALE: Record = { + "buzz-agent": "scale-110", + claude: "scale-110", + codex: "scale-110", + goose: "scale-125", +}; + +const RUNTIME_SORT_PRIORITY: Record = { + "buzz-agent": 0, + goose: 1, +}; -function AuthMethodButtonLabel({ method }: { method: AcpAuthMethod }) { - return <>{method.name || method.id}; +function RuntimeLogo({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { + const avatarUrl = RUNTIME_LOGO_URLS[runtime.id] ?? runtime.avatarUrl; + + return ( + + ); } -function ConnectAccountActions({ +function RuntimeOverflowMenu({ + authMethods, + connectingMethodId, + isConnecting, + onConnect, runtime, }: { + authMethods: AcpAuthMethod[]; + connectingMethodId: string | null; + isConnecting: boolean; + onConnect: (method: AcpAuthMethod) => void; runtime: AcpRuntimeCatalogEntry; }) { - const authMethodsQuery = useAcpAuthMethodsQuery(runtime.id, { - enabled: - runtime.availability === "available" && - runtime.authStatus.status === "logged_out", - }); - const connectMutation = useConnectAcpRuntimeMutation(); - const [terminalLaunchMethodId, setTerminalLaunchMethodId] = React.useState< - string | null - >(null); + const hasInstructions = + runtime.installInstructionsUrl.trim().length > 0 && + (runtime.availability !== "available" || + runtime.authStatus.status === "logged_out" || + runtime.authStatus.status === "config_invalid"); + const hasActions = + runtime.nodeRequired || hasInstructions || authMethods.length > 0; - if (runtime.authStatus.status !== "logged_out") { + if (!hasActions) { return null; } - const methods = authMethodsQuery.data?.methods ?? []; - const isConnecting = connectMutation.isPending; - - function connect(method: AcpAuthMethod) { - connectMutation.mutate( - { runtimeId: runtime.id, methodId: method.id }, - { - onSuccess: (result) => { - if (result.launched && method.type === "terminal") { - setTerminalLaunchMethodId(method.id); - } - }, - }, - ); - } - - if (authMethodsQuery.isLoading) { - return ( -

- Looking for account connection options... -

- ); - } - - if (authMethodsQuery.error instanceof Error) { - return ( -

- Couldn't load account connection options:{" "} - {authMethodsQuery.error.message} -

- ); - } - - if (methods.length === 0) { - return ( -

- This adapter did not advertise a built-in login flow. Use the manual - instructions above, then click Re-run. -

- ); - } - return ( -
-
- {methods.map((method) => { - const pending = - isConnecting && connectMutation.variables?.methodId === method.id; - return ( - - ); - })} -
-

- Buzz launches the adapter's own login flow and then re-checks the{" "} - {runtime.label} CLI. Credentials stay with {runtime.label}. -

- {terminalLaunchMethodId ? ( -

- Finish signing in from the Terminal window, then click Re-run to - re-check {runtime.label}. -

- ) : null} - {methods.map((method) => - method.description ? ( -

- - {method.name || method.id}: - {" "} - {method.description} -

- ) : null, - )} - {connectMutation.error instanceof Error ? ( -

- {connectMutation.error.message} -

- ) : null} -
+ + + + + event.preventDefault()} + > + {authMethods.map((method) => ( + onConnect(method)} + > + {isConnecting && connectingMethodId === method.id ? ( + + ) : null} + {method.name || method.id} + + ))} + {runtime.nodeRequired ? ( + void openUrl("https://nodejs.org")}> + + Install Node.js + + ) : null} + {hasInstructions ? ( + void openUrl(runtime.installInstructionsUrl)} + > + + Instructions + + ) : null} + + ); } -function InstallActions({ - hasError, +function RuntimeActions({ + authMethods, + connectingMethodId, + installSuccess, + isConnecting, isInstalling, + onConnect, onInstall, runtime, }: { - hasError: boolean; + authMethods: AcpAuthMethod[]; + connectingMethodId: string | null; + installSuccess: boolean; + isConnecting: boolean; isInstalling: boolean; + onConnect: (method: AcpAuthMethod) => void; onInstall: () => void; runtime: AcpRuntimeCatalogEntry; }) { - const showInstall = runtime.canAutoInstall && !runtime.nodeRequired; - const installLabel = - runtime.availability === "adapter_missing" - ? "Install ACP adapter" - : runtime.availability === "adapter_outdated" - ? "Update ACP adapter" - : `Install ${runtime.label}`; - const pendingLabel = - runtime.availability === "adapter_missing" || - runtime.availability === "adapter_outdated" - ? "Installing adapter..." - : `Installing ${runtime.label}...`; + const isAvailable = runtime.availability === "available"; + const canInstall = runtime.canAutoInstall && !runtime.nodeRequired; + const isOn = isAvailable || installSuccess; + const isWorking = isInstalling || isConnecting; return ( -
- {showInstall ? ( -

- Buzz uses the official installer and adds the ACP adapter. After it - finishes, Buzz will show the vendor's sign-in flow here. -

- ) : null} -
- {showInstall ? ( - - ) : null} - -
+
+ + {isWorking ? ( +
+ +
+ ) : ( + { + if (checked) { + onInstall(); + } + }} + /> + )}
); } -/** - * Node.js callout when required, or the install actions when it is not. - * Used for both `adapter_missing` and `not_installed` availability states. - * The `cli_missing` branch is intentionally excluded — its install path does - * not involve npm, so no Node.js gate applies. - */ -function NodeRequiredOrInstall({ - hasError, +function RuntimeStatusChip({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { + const label = + runtime.authStatus.status === "config_invalid" + ? "Config error" + : runtime.availability === "adapter_missing" + ? "Adapter needed" + : runtime.availability === "adapter_outdated" + ? "Update needed" + : runtime.availability === "cli_missing" + ? "CLI needed" + : null; + + if (!label) { + return null; + } + + const isConfigError = runtime.authStatus.status === "config_invalid"; + + return ( + <> + + + {label} + + + ); +} + +function RuntimeHeader({ + authMethods, + connectingMethodId, + installSuccess, + isConnecting, isInstalling, + onConnect, onInstall, runtime, }: { - hasError: boolean; + authMethods: AcpAuthMethod[]; + connectingMethodId: string | null; + installSuccess: boolean; + isConnecting: boolean; isInstalling: boolean; + onConnect: (method: AcpAuthMethod) => void; onInstall: () => void; runtime: AcpRuntimeCatalogEntry; }) { - if (runtime.nodeRequired) { - return ( -

- Node.js is required to install this adapter.{" "} - - , then click Re-run. -

- ); - } return ( - +
+
+ +
+

{runtime.label}

+ +
+
+ +
); } @@ -319,181 +291,80 @@ function RuntimeRow({ onInstall: () => void; runtime: AcpRuntimeCatalogEntry; }) { + const [terminalLaunchMethodId, setTerminalLaunchMethodId] = React.useState< + string | null + >(null); + const [isUpdateWarningOpen, setIsUpdateWarningOpen] = React.useState(false); + const canConnectAccount = + runtime.availability === "available" && + runtime.authStatus.status === "logged_out"; + const authMethodsQuery = useAcpAuthMethodsQuery(runtime.id, { + enabled: canConnectAccount, + }); + const authMethods = canConnectAccount + ? (authMethodsQuery.data?.methods ?? []) + : []; + const connectMutation = useConnectAcpRuntimeMutation(); + const connectionError = connectMutation.error + ? `Couldn't connect ${runtime.label}: ${ + connectMutation.error instanceof Error + ? connectMutation.error.message + : "Connection failed." + }` + : authMethodsQuery.error + ? `Couldn't load sign-in options: ${ + authMethodsQuery.error instanceof Error + ? authMethodsQuery.error.message + : "Request failed." + }` + : null; + return (
-
- -
+
+ { + setTerminalLaunchMethodId(null); + connectMutation.mutate( + { + runtimeId: runtime.id, + methodId: method.id, + }, + { + onSuccess: (result) => { + if (result.launched && method.type === "terminal") { + setTerminalLaunchMethodId(method.id); + } + }, + }, + ); + }} + onInstall={() => { + if (runtime.availability === "adapter_outdated") { + setIsUpdateWarningOpen(true); + return; + } + onInstall(); + }} + runtime={runtime} + /> -
-
-

{runtime.label}

- {runtime.command ? ( - - {runtime.command} - - ) : null} -
- - {runtime.availability === "available" && - runtime.command && - runtime.binaryPath ? ( - <> -

- Available via{" "} - {describeResolvedCommand(runtime.command, runtime.binaryPath)}. -

- {runtime.defaultArgs.length > 0 ? ( -

- Default args:{" "} - - {runtime.defaultArgs.join(", ")} - -

- ) : null} - {runtime.underlyingCliPath && - runtime.underlyingCliPath !== runtime.binaryPath ? ( -
-

- CLI:{" "} - {runtime.underlyingCliPath} -

-

- ACP adapter:{" "} - {runtime.binaryPath} -

-
- ) : ( - <> -

- {runtime.binaryPath} -

-

- ACP support built-in — no separate adapter needed. -

- - )} - {/* - * Auth badge renders only for `available` runtimes: non-available - * entries always have auth_status: unknown (no probe was run), which - * AuthStatusBadge maps to null. Rendering it here is self-consistent. - */} - {runtime.authStatus.status !== "not_applicable" && - runtime.authStatus.status !== "unknown" ? ( -
- -
- ) : null} - {/* Login hint shown when not logged in or the config is invalid */} - {runtime.loginHint && - runtime.authStatus.status !== "not_applicable" && - runtime.authStatus.status !== "unknown" ? ( -

- {runtime.authStatus.status === "config_invalid" - ? `Config error: ${runtime.authStatus.diagnostic}` - : runtime.loginHint} -

- ) : null} - - - ) : runtime.availability === "adapter_missing" ? ( - <> -

- CLI detected at{" "} - - {runtime.underlyingCliPath ?? "unknown path"} - {" "} - but ACP adapter not found. -

-

- {runtime.installHint} -

- - - ) : runtime.availability === "adapter_outdated" ? ( - <> -

- ACP adapter found at{" "} - - {runtime.binaryPath ?? "unknown path"} - {" "} - but it is from the deprecated package. Reinstall to enable relay - connectivity. -

-

- This updates the machine-global{" "} - - codex-acp - {" "} - adapter. Older Buzz releases using the legacy adapter contract may - lose community access until{" "} - - @zed-industries/codex-acp@0.16.0 - {" "} - is restored. -

-

- {runtime.installHint} -

- - - ) : runtime.availability === "cli_missing" ? ( - <> -

- ACP adapter found at{" "} - - {runtime.binaryPath ?? "unknown path"} - {" "} - but the {runtime.label} CLI is not installed. -

-

- {runtime.installHint} -

- - - ) : ( - <> -

- Not installed -

-

- {runtime.installHint} -

- - - )} + {runtime.authStatus.status === "config_invalid" ? ( +

+ Config error: {runtime.authStatus.diagnostic} +

+ ) : null} {installSuccess && runtime.availability !== "available" ? (

@@ -505,12 +376,53 @@ function RuntimeRow({ {installError}

) : null} + {connectionError ? ( +

+ {connectionError} +

+ ) : null} + {canConnectAccount && terminalLaunchMethodId ? ( +

+ Finish signing in from the Terminal window, then click Check again + to re-check {runtime.label}. +

+ ) : null}
+ + + + Update {runtime.label} adapter? + + This replaces the machine-wide codex-acp adapter. Older Buzz + releases using the legacy adapter may lose community access until + @zed-industries/codex-acp@0.16.0 is restored. + + + + Cancel + + Update + + + +
); } -function GitBashRow({ +function GitBashCard({ prerequisite, }: { prerequisite: NonNullable< @@ -519,39 +431,48 @@ function GitBashRow({ }) { return (
-
- {prerequisite.available ? ( - - ) : ( - - )} -
-
-

Git Bash

- {prerequisite.available ? ( -

- {prerequisite.path} -

- ) : ( - <> -

- Required for buzz-agent shell tools on Windows. -

-

- {prerequisite.installHint} -

+
+
+
+

Git Bash

+ + + {prerequisite.available ? "Available" : "Action needed"} + +
+ {!prerequisite.available ? ( - - )} + ) : null} +
+ {!prerequisite.available ? ( +
+

Required for buzz-agent shell tools on Windows.

+

{prerequisite.installHint}

+
+ ) : null}
); @@ -560,7 +481,15 @@ function GitBashRow({ export function DoctorSettingsPanel() { const runtimesQuery = useAcpRuntimesQuery(); const gitBashQuery = useGitBashPrerequisiteQuery(); - const runtimes = runtimesQuery.data ?? []; + const runtimes = React.useMemo( + () => + [...(runtimesQuery.data ?? [])].sort( + (left, right) => + (RUNTIME_SORT_PRIORITY[left.id] ?? Number.MAX_SAFE_INTEGER) - + (RUNTIME_SORT_PRIORITY[right.id] ?? Number.MAX_SAFE_INTEGER), + ), + [runtimesQuery.data], + ); const isRefreshing = runtimesQuery.isFetching; const installMutation = useInstallAcpRuntimeMutation(); const [installResults, setInstallResults] = React.useState< @@ -617,10 +546,14 @@ export function DoctorSettingsPanel() { } return ( -
- + - Re-run + Check again } /> -
- - {gitBashQuery.data ? ( - <> -
-

- System prerequisites -

-

- Windows tools required by supported agents. -

-
- - - ) : null} -
-

- Agent CLIs and ACP runtimes -

-

- Installation status of supported agent CLIs and their ACP - runtimes. -

-
+
+ {gitBashQuery.data ? ( +
+
+

+ System prerequisites +

+

+ Windows tools required by supported agents. +

+
+ +
+ ) : null} +
{runtimesQuery.isLoading ? ( -
- Looking for ACP runtimes... +
+ Checking agent runtimes...
) : runtimes.length > 0 ? ( - runtimes.map((runtime) => ( - handleInstall(runtime.id)} - runtime={runtime} - /> - )) +
+ {runtimes.map((runtime) => ( + handleInstall(runtime.id)} + runtime={runtime} + /> + ))} +
) : ( -
- No known ACP runtimes found. +
+ No supported agent runtimes found.
)} {runtimesQuery.error instanceof Error ? ( -

+

{runtimesQuery.error.message}

) : null} - +
); diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index fcfa457986..f875be64c8 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -18,7 +18,6 @@ import { ShieldAlert, Smartphone, Smile, - Stethoscope, Sun, SunMoon, UserRound, @@ -100,8 +99,7 @@ export type SettingsSection = | "custom-emoji" | "local-archive" | "mobile" - | "updates" - | "doctor"; + | "updates"; export const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile"; @@ -121,7 +119,6 @@ const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [ "local-archive", "mobile", "updates", - "doctor", ]; export function isSettingsSection(value: unknown): value is SettingsSection { @@ -233,12 +230,6 @@ export const settingsSections: SettingsSectionDescriptor[] = [ label: "Updates", icon: Download, }, - { - value: "doctor", - label: "Doctor", - icon: Stethoscope, - featureGate: "doctor", - }, ]; function formatThemeLabel(name: string): string { @@ -822,6 +813,7 @@ export function renderSettingsSection( return (
+
); @@ -849,8 +841,6 @@ export function renderSettingsSection( return ; case "updates": return ; - case "doctor": - return ; default: { const exhaustiveCheck: never = section; return exhaustiveCheck; diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 5c3fb4b9f1..5c54dc8180 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -66,14 +66,7 @@ const settingsNavGroups: Array<{ }, { label: "App", - sections: [ - "agents", - "compute", - "experimental", - "mobile", - "updates", - "doctor", - ], + sections: ["agents", "compute", "experimental", "mobile", "updates"], }, ]; diff --git a/desktop/src/shared/ui/config-nudge-attachment.tsx b/desktop/src/shared/ui/config-nudge-attachment.tsx index 4be3e63980..e1294b7dde 100644 --- a/desktop/src/shared/ui/config-nudge-attachment.tsx +++ b/desktop/src/shared/ui/config-nudge-attachment.tsx @@ -43,7 +43,7 @@ function requirementKey( /** * Returns true when every requirement in the nudge is a `cli_login` surface. * Non-authOnly all-cli_login cards (at least one install-state row) route to - * Doctor — install/login problems can't be fixed in Edit Agent. AuthOnly cards + * Agent runtimes — install/login problems can't be fixed in Edit Agent. AuthOnly cards * (every row is `availability === "available"`) are purely informational and * do not route anywhere. */ @@ -65,8 +65,8 @@ export function shouldOpenDoctor( /** * Returns true when the card is all-cli_login AND every requirement is in the - * `available` state (tooling installed, just needs login). In this case Doctor - * has no auth functionality and is a misleading dead-end — the card becomes + * `available` state (tooling installed, just needs login). In this case Agent + * runtimes has no auth functionality and is a misleading dead-end — the card becomes * purely informational (no trigger, no CTA, no pointer/hover affordance). */ function isAuthOnly(reqs: ConfigNudgePayload["requirements"]): boolean { @@ -140,7 +140,7 @@ function firstFocusTarget( * Mirrors `firstFocusTarget` but operates on one row — used so per-row * Edit Agent CTAs focus the field that row describes, not the first editable * field on the card. - * Returns `undefined` for `cli_login` requirements (Doctor, not Edit Agent). + * Returns `undefined` for `cli_login` requirements (Agent runtimes, not Edit Agent). */ export function focusTargetForRequirement( req: ConfigNudgePayload["requirements"][number], @@ -164,15 +164,15 @@ export function focusTargetForRequirement( * * Routing: * (A) Any card with a `git_bash` requirement, or one whose requirements are all - * install-state `cli_login`, opens Settings → Doctor. A card-level - * "Open Doctor →" label in `AttachmentActions` confirms the action at rest. + * install-state `cli_login`, opens Settings → Agent runtimes. A card-level + * Agent runtimes label in `AttachmentActions` confirms the action at rest. * (A-auth) A card whose requirements are all available `cli_login` surfaces is - * purely informational: Doctor cannot authenticate a CLI, and `setup_copy` + * purely informational: Agent runtimes cannot authenticate a CLI, and `setup_copy` * already gives the needed command. * (B) Other mixed cards open Edit Agent as the card-level fallback. Their rows * carry inline CTAs for the matching destination: install-state `cli_login` - * opens Doctor; `env_key` and `normalized_field` open Edit Agent. A - * `git_bash` row is covered by the card-level Doctor route, so it does not + * opens Agent runtimes; `env_key` and `normalized_field` open Edit Agent. A + * `git_bash` row is covered by the card-level Agent runtimes route, so it does not * render a redundant row action. */ export function ConfigNudgeCard({ @@ -196,10 +196,10 @@ export function ConfigNudgeCard({ const openDoctor = () => { if (!onOpenSettings) { console.warn( - "[ConfigNudgeCard] onOpenSettings is null — Doctor deep-link unavailable on this surface", + "[ConfigNudgeCard] onOpenSettings is null — Agent runtimes deep-link unavailable on this surface", ); } - onOpenSettings?.("doctor"); + onOpenSettings?.("agents"); }; const openEditAgent = (focus?: EditAgentFocusTarget) => { @@ -209,7 +209,7 @@ export function ConfigNudgeCard({ const handleOpen = () => { if (shouldOpenDoctor(nudge.requirements)) { - // Git Bash and install-state CLI requirements both resolve in Doctor. + // Git Bash and install-state CLI requirements both resolve in Agent runtimes. // Informational-only cards never mount this trigger. openDoctor(); } else { @@ -219,7 +219,7 @@ export function ConfigNudgeCard({ }; const handleOpenDoctor = (e: React.MouseEvent) => { - // (B) Per-row Doctor CTA — stop propagation so the card trigger doesn't + // (B) Per-row Agent runtimes CTA — stop propagation so the card trigger doesn't // double-fire to Edit Agent on mixed cards. e.stopPropagation(); openDoctor(); @@ -266,11 +266,13 @@ export function ConfigNudgeCard({ ))}
- {/* (A) Doctor-routed cards have one card-level CTA. Informational-only + {/* (A) Agent-runtime-routed cards have one card-level CTA. Informational-only cards have none; other mixed cards render their own row CTAs. */} {opensDoctor && !informationalOnly && ( - Open Doctor → + + Open Agent runtimes → + )} {/* Informational-only cards are purely informational — no trigger, no routing. */} @@ -278,7 +280,7 @@ export function ConfigNudgeCard({ {cliLoginMessage(requirement)} - {/* (B) Per-row Doctor CTA — shown only on mixed cards where the + {/* (B) Per-row Agent runtimes CTA — shown only on mixed cards where the card-level trigger opens Edit Agent (not auth-only cards). When - allCliLogin is true the card trigger already routes to Doctor; the + allCliLogin is true the card trigger already routes to Agent runtimes; the per-row button is redundant and is suppressed. Also suppressed for - `available` cli_login rows — Doctor has no auth functionality and + `available` cli_login rows — Agent runtimes has no auth functionality and the setup_copy already provides the exact login command. stopPropagation prevents double-fire on mixed cards where both card and row CTAs are visible. */} @@ -366,7 +368,7 @@ function RequirementRow({ onClick={onOpenDoctor} type="button" > - Open Doctor → + Open Agent runtimes → )}
@@ -381,7 +383,7 @@ function RequirementRow({ ); case "cli_config_invalid": { // Config-invalid rows are purely informational — the user must edit an - // external file. No Doctor CTA (Doctor can't repair ~/.codex/config.toml) + // external file. No Agent runtimes CTA (Buzz can't repair ~/.codex/config.toml) // and no Edit Agent CTA (the field isn't managed by Buzz). const cli = requirement.probe_args[0] ?? "the CLI"; const configFile = `~/.${cli}/config.toml`; diff --git a/desktop/src/shared/ui/switch.tsx b/desktop/src/shared/ui/switch.tsx index 420fee2d34..c22ae90316 100644 --- a/desktop/src/shared/ui/switch.tsx +++ b/desktop/src/shared/ui/switch.tsx @@ -9,7 +9,7 @@ const Switch = React.forwardRef< >(({ className, ...props }, ref) => ( diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 83c84a40c7..67bcb3f96f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -128,11 +128,13 @@ type E2eConfig = { acpRuntimesCatalog?: RawAcpRuntimeCatalogEntry[]; acpRuntimesDelayMs?: number; acpAuthMethods?: Record; + acpAuthMethodsErrors?: Record; acpAuthMethodsError?: string; connectAcpRuntimeResult?: RawConnectAcpRuntimeResult; connectAcpRuntimeDelayMs?: number; connectAcpRuntimeError?: string; activePersonaIds?: string[]; + installAcpRuntimeDelayMs?: number; installAcpRuntimeResult?: RawInstallRuntimeResult; /** Sequence of results for successive `install_acp_runtime` calls. * Call N returns results[N]; when exhausted the last entry repeats. @@ -6700,11 +6702,15 @@ async function handleDiscoverAcpAuthMethods( args: { runtimeId?: string }, config: E2eConfig | undefined, ): Promise { - const error = config?.mock?.acpAuthMethodsError; - if (error) { - throw new Error(error); + const globalError = config?.mock?.acpAuthMethodsError; + if (globalError) { + throw new Error(globalError); } const runtimeId = args.runtimeId ?? ""; + const perRuntimeError = config?.mock?.acpAuthMethodsErrors?.[runtimeId]; + if (perRuntimeError) { + throw new Error(perRuntimeError); + } const configured = config?.mock?.acpAuthMethods?.[runtimeId]; if (configured) { return configured; @@ -6750,6 +6756,10 @@ async function handleInstallAcpRuntime( }, config: E2eConfig | undefined, ): Promise { + const delayMs = config?.mock?.installAcpRuntimeDelayMs ?? 0; + if (delayMs > 0) { + await new Promise((resolve) => window.setTimeout(resolve, delayMs)); + } const sequence = config?.mock?.installAcpRuntimeResults; if (sequence && sequence.length > 0) { const idx = Math.min(installCallCount, sequence.length - 1); diff --git a/desktop/tests/e2e/doctor-cta-screenshots.spec.ts b/desktop/tests/e2e/doctor-cta-screenshots.spec.ts index 9bd2732175..cde18b7ff0 100644 --- a/desktop/tests/e2e/doctor-cta-screenshots.spec.ts +++ b/desktop/tests/e2e/doctor-cta-screenshots.spec.ts @@ -155,8 +155,8 @@ test.describe("doctor CTA nudge card screenshots", () => { // Wait for the nudge card to render. const card = page.locator("[data-config-nudge]").last(); await expect(card).toBeVisible({ timeout: 10_000 }); - // Auth-only card is informational — no Doctor CTA anywhere. - await expect(card.getByText("Open Doctor →")).toHaveCount(0); + // Auth-only card is informational — no runtime settings CTA anywhere. + await expect(card.getByText("Open Agent runtimes →")).toHaveCount(0); await card.scrollIntoViewIfNeeded(); await settleAnimations(page); @@ -168,7 +168,7 @@ test.describe("doctor CTA nudge card screenshots", () => { /** * 02 — not_installed state: neither adapter nor CLI found. - * Card shows "claude isn't installed" copy + inline "Open Doctor →" CTA. + * Card shows "claude isn't installed" copy + an Agent runtimes CTA. */ test("02-cli-login-not-installed-state", async ({ page }) => { await installMockBridge(page, { @@ -210,7 +210,7 @@ test.describe("doctor CTA nudge card screenshots", () => { /** * 03 — mixed card: one cli_login (adapter_missing) + one env_key requirement. * Each requirement row owns its CTA, right-aligned to a shared edge: - * the cli_login row shows "Open Doctor →" and the env_key row shows + * the cli_login row opens Agent runtimes and the env_key row shows * "Edit Agent →", both at the same x (vertically aligned). */ test("03-mixed-requirements-inline-doctor-cta", async ({ page }) => { @@ -244,8 +244,8 @@ test.describe("doctor CTA nudge card screenshots", () => { const card = page.locator("[data-config-nudge]").last(); await expect(card).toBeVisible({ timeout: 10_000 }); - // Mixed card: cli_login row shows "Open Doctor →", env_key row shows "Edit Agent →". - await expect(card.getByText("Open Doctor →")).toBeVisible(); + // Mixed card: cli_login opens Agent runtimes; env_key opens Edit Agent. + await expect(card.getByText("Open Agent runtimes →")).toBeVisible(); // Both per-row CTAs share the same right edge (vertically aligned). await expect(card.getByText("Edit Agent →", { exact: true })).toBeVisible(); diff --git a/desktop/tests/e2e/doctor-states.spec.ts b/desktop/tests/e2e/doctor-states.spec.ts index 6675ef23b3..1a724f719a 100644 --- a/desktop/tests/e2e/doctor-states.spec.ts +++ b/desktop/tests/e2e/doctor-states.spec.ts @@ -111,9 +111,116 @@ test.describe("Doctor panel state screenshots", () => { }); /** - * 01 — available runtime that passed the auth probe: green "Authenticated" - * badge appears below the binary path. + * 00 — the runtime catalog reads as a set of individual status cards rather + * than one continuous table. */ + test("00-runtime-card-layout", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + GOOSE_AVAILABLE, + CLAUDE_AVAILABLE_LOGGED_IN, + CODEX_NOT_INSTALLED, + BUZZ_AGENT_AVAILABLE, + ], + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "agents"); + + const runtimeList = page.getByTestId("doctor-runtime-list"); + await expect(runtimeList).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("doctor-runtime-goose")).toBeVisible(); + await expect(page.getByTestId("doctor-runtime-codex")).toBeVisible(); + await expect( + runtimeList.locator(":scope > [data-testid^='doctor-runtime-']"), + ).toHaveCount(4); + expect( + await runtimeList + .locator(":scope > [data-testid^='doctor-runtime-']") + .evaluateAll((rows) => + rows.map((row) => row.getAttribute("data-testid")), + ), + ).toEqual([ + "doctor-runtime-buzz-agent", + "doctor-runtime-goose", + "doctor-runtime-claude", + "doctor-runtime-codex", + ]); + for (const runtimeId of ["goose", "claude", "codex", "buzz-agent"]) { + await expect( + page.getByTestId(`doctor-runtime-logo-${runtimeId}`), + ).toBeVisible(); + } + const rowHeights = await Promise.all( + ["goose", "claude", "codex", "buzz-agent"].map((runtimeId) => + page + .getByTestId(`doctor-runtime-${runtimeId}`) + .evaluate((element) => + Math.round(element.getBoundingClientRect().height), + ), + ), + ); + expect(new Set(rowHeights).size).toBe(1); + const [gooseColors, codexColors] = await Promise.all( + ["goose", "codex"].map((runtimeId) => + page.getByTestId(`doctor-runtime-${runtimeId}`).evaluate((element) => { + const styles = getComputedStyle(element); + return { + backgroundColor: styles.backgroundColor, + borderColor: styles.borderColor, + }; + }), + ), + ); + expect(codexColors).toEqual(gooseColors); + await expect( + page + .getByRole("heading", { name: "Agent runtimes" }) + .locator("..") + .locator(".."), + ).toHaveCSS("align-items", "center"); + for (const runtimeId of ["goose", "claude", "buzz-agent"]) { + await expect( + page.getByTestId(`doctor-runtime-menu-${runtimeId}`), + ).toHaveCount(0); + } + await expect( + page.getByTestId("doctor-runtime-toggle-codex"), + ).not.toBeChecked(); + await expect(page.getByTestId("doctor-runtime-toggle-codex")).toBeEnabled(); + for (const runtimeId of ["goose", "codex"]) { + const toggle = page.getByTestId(`doctor-runtime-toggle-${runtimeId}`); + await expect(toggle).toHaveClass(/shadow-none/); + await expect(toggle.locator("span")).toHaveClass(/shadow-none/); + } + await expect( + page.getByRole("menuitem", { name: "Instructions" }), + ).toHaveCount(0); + await page.getByTestId("doctor-runtime-menu-codex").click(); + await expect( + page.getByRole("menuitem", { name: "Instructions" }), + ).toBeVisible(); + await waitForAnimations(page); + await page.screenshot({ + path: `${SHOTS}/00-runtime-overflow-menu.png`, + }); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("doctor-runtime-toggle-goose")).toBeChecked(); + await expect( + page.getByTestId("doctor-runtime-toggle-goose"), + ).toBeDisabled(); + await expect(page.getByTestId("doctor-runtime-codex")).not.toContainText( + "Not installed", + ); + + await runtimeList.scrollIntoViewIfNeeded(); + await waitForAnimations(page); + await runtimeList.screenshot({ + path: `${SHOTS}/00-runtime-card-layout.png`, + }); + }); + + /** 01 — a ready runtime stays compact without redundant status copy. */ test("01-auth-logged-in", async ({ page }) => { await installMockBridge(page, { acpRuntimesCatalog: [ @@ -125,11 +232,18 @@ test.describe("Doctor panel state screenshots", () => { }); await page.goto("/", { waitUntil: "domcontentloaded" }); - await openSettings(page, "doctor"); + await openSettings(page, "agents"); const row = page.getByTestId("doctor-runtime-claude"); await expect(row).toBeVisible({ timeout: 10_000 }); - await expect(row).toContainText("Authenticated"); + await expect( + page.getByTestId("doctor-runtime-toggle-claude"), + ).toBeChecked(); + await expect(row).not.toContainText("Authenticated"); + await expect(row).not.toContainText("Available"); + await expect(row).not.toContainText("claude-agent-acp"); + await expect(row).not.toContainText("/usr/local/bin"); + await expect(page.getByTestId("doctor-runtime-menu-claude")).toHaveCount(0); await row.scrollIntoViewIfNeeded(); await waitForAnimations(page); @@ -137,8 +251,8 @@ test.describe("Doctor panel state screenshots", () => { }); /** - * 02 — available runtime that failed the auth probe: amber "Not - * authenticated" badge + login hint shown below the binary path. + * 02 — an available runtime that needs authentication stays the same height + * as the others and moves setup instructions into its overflow menu. */ test("02-auth-logged-out", async ({ page }) => { await installMockBridge(page, { @@ -159,12 +273,23 @@ test.describe("Doctor panel state screenshots", () => { }); await page.goto("/", { waitUntil: "domcontentloaded" }); - await openSettings(page, "doctor"); + await openSettings(page, "agents"); const row = page.getByTestId("doctor-runtime-codex"); await expect(row).toBeVisible({ timeout: 10_000 }); - await expect(row).toContainText("Not authenticated"); - await expect(row).toContainText("Run `codex login` to authenticate."); + await expect(row).not.toContainText("Not authenticated"); + await expect(row).not.toContainText("Run `codex login` to authenticate."); + await expect(row).toHaveCSS( + "height", + await page + .getByTestId("doctor-runtime-goose") + .evaluate((element) => getComputedStyle(element).height), + ); + await page.getByTestId("doctor-runtime-menu-codex").click(); + await expect( + page.getByRole("menuitem", { name: "Instructions" }), + ).toBeVisible(); + await page.keyboard.press("Escape"); await row.scrollIntoViewIfNeeded(); await waitForAnimations(page); @@ -172,8 +297,8 @@ test.describe("Doctor panel state screenshots", () => { }); /** - * 03 — available runtime whose CLI has a config-parse error: red "Config - * error" badge + diagnostic excerpt shown below the binary path. + * 03 — a runtime with invalid configuration exposes its diagnostic and keeps + * setup instructions in overflow. */ test("03-auth-config-error", async ({ page }) => { const diagnostic = @@ -192,12 +317,23 @@ test.describe("Doctor panel state screenshots", () => { }); await page.goto("/", { waitUntil: "domcontentloaded" }); - await openSettings(page, "doctor"); + await openSettings(page, "agents"); const row = page.getByTestId("doctor-runtime-claude"); await expect(row).toBeVisible({ timeout: 10_000 }); - await expect(row).toContainText("Config error"); - await expect(row).toContainText("error loading configuration"); + await expect(page.getByTestId("doctor-runtime-status-claude")).toHaveText( + "Config error", + ); + await expect( + page.getByTestId("doctor-runtime-config-error-claude"), + ).toContainText( + "Config error: error loading configuration: ~/.claude/settings.json: unknown key foo", + ); + await page.getByTestId("doctor-runtime-menu-claude").click(); + await expect( + page.getByRole("menuitem", { name: "Instructions" }), + ).toBeVisible(); + await page.keyboard.press("Escape"); await row.scrollIntoViewIfNeeded(); await waitForAnimations(page); @@ -205,9 +341,8 @@ test.describe("Doctor panel state screenshots", () => { }); /** - * 04 — adapter_missing runtime with node_required: true: the amber "Node.js - * is required…" callout replaces the Install button so the user cannot - * inadvertently trigger a doomed npm install. + * 04 — adapter_missing runtime with node_required: true: the off toggle is + * disabled, and the Node.js action moves into the overflow menu. */ test("04-node-required", async ({ page }) => { await installMockBridge(page, { @@ -227,15 +362,28 @@ test.describe("Doctor panel state screenshots", () => { }); await page.goto("/", { waitUntil: "domcontentloaded" }); - await openSettings(page, "doctor"); + await openSettings(page, "agents"); const row = page.getByTestId("doctor-runtime-codex"); await expect(row).toBeVisible({ timeout: 10_000 }); - await expect(row).toContainText("Node.js is required"); - // Exact-name match so "Install Node.js" (inside the callout) is not counted. + const toggle = page.getByTestId("doctor-runtime-toggle-codex"); + await expect(toggle).not.toBeChecked(); + await expect(toggle).toBeDisabled(); + await expect(page.getByTestId("doctor-runtime-status-codex")).toHaveText( + "Adapter needed", + ); + await expect(row).not.toContainText("Node.js is required"); + await expect(row).toHaveCSS( + "height", + await page + .getByTestId("doctor-runtime-goose") + .evaluate((element) => getComputedStyle(element).height), + ); + await page.getByTestId("doctor-runtime-menu-codex").click(); await expect( - row.getByRole("button", { name: "Install", exact: true }), - ).toHaveCount(0); + page.getByRole("menuitem", { name: "Install Node.js" }), + ).toBeVisible(); + await page.keyboard.press("Escape"); await row.scrollIntoViewIfNeeded(); await waitForAnimations(page); @@ -243,13 +391,13 @@ test.describe("Doctor panel state screenshots", () => { }); /** - * 05 — a failed install renders a "Retry" button; clicking Retry succeeds. + * 05 — a failed toggle install returns to off; toggling again retries. * * The mock is configured with a two-call sequence: * call 1 → failure (E404) * call 2 → success - * This exercises the full Retry UX path: fail state → click Retry → - * spinner → success banner. + * This exercises the full retry path: fail state → toggle on again → + * success banner. */ test("05-retry-after-failure", async ({ page }) => { await installMockBridge(page, { @@ -263,6 +411,7 @@ test.describe("Doctor panel state screenshots", () => { }, BUZZ_AGENT_AVAILABLE, ], + installAcpRuntimeDelayMs: 250, installAcpRuntimeResults: [ { success: false, @@ -294,19 +443,26 @@ test.describe("Doctor panel state screenshots", () => { }); await page.goto("/", { waitUntil: "domcontentloaded" }); - await openSettings(page, "doctor"); + await openSettings(page, "agents"); const row = page.getByTestId("doctor-runtime-codex"); await expect(row).toBeVisible({ timeout: 10_000 }); + await expect(row).not.toContainText("Not installed"); // Trigger the first install — the mock returns a failure. - const installBtn = row.getByRole("button", { name: "Install Codex" }); - await expect(installBtn).toBeVisible({ timeout: 5_000 }); - await installBtn.click(); - - // After failure: Retry button appears and the error message is visible. - const retryBtn = row.getByRole("button", { name: "Retry Install Codex" }); - await expect(retryBtn).toBeVisible({ timeout: 5_000 }); + const toggle = page.getByTestId("doctor-runtime-toggle-codex"); + await expect(toggle).not.toBeChecked(); + await expect(toggle).toBeEnabled(); + await toggle.click(); + const loading = page.getByTestId("doctor-runtime-loading-codex"); + await expect(loading).toBeVisible(); + await expect(loading).toContainText("Codex installing"); + await expect(toggle).toHaveCount(0); + + // After failure: the toggle returns to off and the error is visible. + await expect(loading).toHaveCount(0, { timeout: 5_000 }); + await expect(toggle).not.toBeChecked({ timeout: 5_000 }); + await expect(toggle).toBeEnabled(); await expect(row).toContainText("Step"); await expect(row).toContainText("failed"); @@ -314,17 +470,21 @@ test.describe("Doctor panel state screenshots", () => { await waitForAnimations(page); await row.screenshot({ path: `${SHOTS}/05-retry-after-failure.png` }); - // Click Retry — the mock returns success on the second call. - await retryBtn.click(); + // Toggle on again — the mock returns success on the second call. + await toggle.click(); + await expect(loading).toBeVisible(); + await expect(toggle).toHaveCount(0); - // Error paragraph must disappear and per-runtime spinner must appear, - // then the success banner must render. + // The error disappears, then the success banner and on state render. + await expect(loading).toHaveCount(0, { timeout: 5_000 }); await expect(row).not.toContainText("failed", { timeout: 5_000 }); await expect( row.getByText("Codex installed. Checking for sign-in options..."), ).toBeVisible({ timeout: 10_000, }); + await expect(toggle).toBeChecked(); + await expect(toggle).toBeDisabled(); await row.scrollIntoViewIfNeeded(); await waitForAnimations(page); @@ -332,9 +492,8 @@ test.describe("Doctor panel state screenshots", () => { }); /** - * 06 — logged-out runtime with adapter-advertised auth methods: Doctor shows - * adapter-provided labels/descriptions and clicking one launches the - * vendor-owned flow through the mocked connect command. + * 06 — adapter-provided account methods appear in the overflow menu and + * launch the vendor-owned flow without expanding the runtime row. */ test("06-connect-account-methods", async ({ page }) => { await installMockBridge(page, { @@ -368,28 +527,37 @@ test.describe("Doctor panel state screenshots", () => { }); await page.goto("/", { waitUntil: "domcontentloaded" }); - await openSettings(page, "doctor"); + await openSettings(page, "agents"); const row = page.getByTestId("doctor-runtime-codex"); await expect(row).toBeVisible({ timeout: 10_000 }); - await expect(row).toContainText("Not authenticated"); - await expect(row).toContainText("Sign in with ChatGPT"); - await expect(row).toContainText( - "Use your Codex subscription in the browser.", + await expect(row).not.toContainText("Not authenticated"); + await expect(row).toHaveCSS( + "height", + await page + .getByTestId("doctor-runtime-goose") + .evaluate((element) => getComputedStyle(element).height), ); - await expect(row).toContainText("Credentials stay with Codex."); - - await row.getByRole("button", { name: "Sign in with ChatGPT" }).click(); + await page.getByTestId("doctor-runtime-menu-codex").click(); await expect( - row.getByRole("button", { name: "Connecting..." }), + page.getByRole("menuitem", { name: "Sign in with ChatGPT" }), ).toBeVisible({ timeout: 5_000, }); + await page.getByRole("menuitem", { name: "Sign in with ChatGPT" }).click(); + const loading = page.getByTestId("doctor-runtime-loading-codex"); + await expect(loading).toBeVisible(); + await expect(loading).toContainText("Codex connecting"); + await expect(page.getByTestId("doctor-runtime-toggle-codex")).toHaveCount( + 0, + ); + await expect(loading).toHaveCount(0, { timeout: 5_000 }); + await expect(page.getByTestId("doctor-runtime-toggle-codex")).toBeChecked(); }); /** - * 07 — old or constrained adapter with no advertised auth methods: Doctor - * falls back to manual instructions instead of inventing a login command. + * 07 — an adapter with no advertised auth methods shows only its manual + * instructions in overflow and keeps the row compact. */ test("07-connect-account-no-methods", async ({ page }) => { await installMockBridge(page, { @@ -409,14 +577,162 @@ test.describe("Doctor panel state screenshots", () => { }); await page.goto("/", { waitUntil: "domcontentloaded" }); - await openSettings(page, "doctor"); + await openSettings(page, "agents"); const row = page.getByTestId("doctor-runtime-claude"); await expect(row).toBeVisible({ timeout: 10_000 }); - await expect(row).toContainText("Not authenticated"); - await expect(row).toContainText( - "This adapter did not advertise a built-in login flow.", + await expect(row).not.toContainText("Not authenticated"); + await expect(row).toHaveCSS( + "height", + await page + .getByTestId("doctor-runtime-goose") + .evaluate((element) => getComputedStyle(element).height), + ); + await page.getByTestId("doctor-runtime-menu-claude").click(); + await expect( + page.getByRole("menuitem", { name: "Instructions" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Sign in with ChatGPT" }), + ).toHaveCount(0); + }); + + test("08-auth-method-discovery-error", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + GOOSE_AVAILABLE, + CLAUDE_AVAILABLE_LOGGED_IN, + { + ...CODEX_NOT_INSTALLED, + availability: "available", + auth_status: { status: "logged_out" }, + }, + BUZZ_AGENT_AVAILABLE, + ], + acpAuthMethodsErrors: { + codex: "Could not inspect the Codex adapter.", + }, + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "agents"); + + await expect(page.getByTestId("doctor-runtime-error-codex")).toContainText( + "Couldn't load sign-in options: Could not inspect the Codex adapter.", + ); + }); + + test("09-connect-account-error", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + GOOSE_AVAILABLE, + CLAUDE_AVAILABLE_LOGGED_IN, + { + ...CODEX_NOT_INSTALLED, + availability: "available", + auth_status: { status: "logged_out" }, + }, + BUZZ_AGENT_AVAILABLE, + ], + acpAuthMethods: { + codex: { + methods: [ + { + id: "chat-gpt", + name: "Sign in with ChatGPT", + type: "browser", + }, + ], + }, + }, + connectAcpRuntimeError: "The browser could not be opened.", + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "agents"); + + await page.getByTestId("doctor-runtime-menu-codex").click(); + await page.getByRole("menuitem", { name: "Sign in with ChatGPT" }).click(); + await expect(page.getByTestId("doctor-runtime-error-codex")).toContainText( + "Couldn't connect Codex: The browser could not be opened.", ); - await expect(row).not.toContainText("Connect account"); + }); + + test("10-terminal-auth-completion-guidance", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + GOOSE_AVAILABLE, + CLAUDE_AVAILABLE_LOGGED_IN, + { + ...CODEX_NOT_INSTALLED, + availability: "available", + auth_status: { status: "logged_out" }, + }, + BUZZ_AGENT_AVAILABLE, + ], + acpAuthMethods: { + codex: { + methods: [ + { + id: "terminal-login", + name: "Sign in from Terminal", + type: "terminal", + }, + ], + }, + }, + connectAcpRuntimeResult: { launched: true }, + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "agents"); + + await page.getByTestId("doctor-runtime-menu-codex").click(); + await page.getByRole("menuitem", { name: "Sign in from Terminal" }).click(); + await expect( + page.getByTestId("doctor-runtime-terminal-guidance-codex"), + ).toContainText( + "Finish signing in from the Terminal window, then click Check again to re-check Codex.", + ); + }); + + test("11-outdated-adapter-warning", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + GOOSE_AVAILABLE, + CLAUDE_AVAILABLE_LOGGED_IN, + { + ...CODEX_NOT_INSTALLED, + availability: "adapter_outdated", + binary_path: "/usr/local/bin/codex-acp", + underlying_cli_path: "/usr/local/bin/codex", + can_auto_install: true, + }, + BUZZ_AGENT_AVAILABLE, + ], + installAcpRuntimeDelayMs: 250, + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "agents"); + + await expect(page.getByTestId("doctor-runtime-status-codex")).toHaveText( + "Update needed", + ); + await page.getByTestId("doctor-runtime-toggle-codex").click(); + + const dialog = page.getByRole("alertdialog"); + await expect(dialog).toContainText("Update Codex adapter?"); + await expect(dialog).toContainText( + "Older Buzz releases using the legacy adapter may lose community access", + ); + await expect(page.getByTestId("doctor-runtime-loading-codex")).toHaveCount( + 0, + ); + + await page.getByTestId("doctor-runtime-confirm-update-codex").click(); + const loading = page.getByTestId("doctor-runtime-loading-codex"); + await expect(loading).toBeVisible(); + await expect(loading).toContainText("Codex installing"); }); }); diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 048f206e03..a945d159b2 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -1537,11 +1537,11 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => { }); }); -test("shows doctor checks for local CLI tooling", async ({ page }) => { +test("shows agent runtimes in agent settings", async ({ page }) => { await page.goto("/"); - await openSettings(page, "doctor"); + await openSettings(page, "agents"); - await expect(page.getByTestId("settings-doctor")).toBeVisible(); + await expect(page.getByTestId("settings-agent-runtimes")).toBeVisible(); await expect(page.getByTestId("doctor-runtime-goose")).toContainText("Goose"); }); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index f4fe926817..288df20753 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -134,6 +134,7 @@ type MockBridgeOptions = { connectAcpRuntimeResult?: { launched: boolean }; connectAcpRuntimeDelayMs?: number; connectAcpRuntimeError?: string; + installAcpRuntimeDelayMs?: number; /** Override the result returned by the `install_acp_runtime` mock command. * Pass `{ success: false, steps: [...] }` to exercise error/Retry states. */ installAcpRuntimeResult?: { diff --git a/desktop/tests/helpers/settings.ts b/desktop/tests/helpers/settings.ts index 7763905a39..301e622719 100644 --- a/desktop/tests/helpers/settings.ts +++ b/desktop/tests/helpers/settings.ts @@ -11,8 +11,7 @@ type SettingsSection = | "tokens" | "community-members" | "mobile" - | "updates" - | "doctor"; + | "updates"; export async function openProfileMenu(page: Page) { await page.getByTestId("open-settings").click();