From ca339362083b77ae993a1bdf128fd97c5710793d Mon Sep 17 00:00:00 2001 From: Max Hsu Date: Wed, 24 Jun 2026 15:15:05 +0800 Subject: [PATCH] fix(onboarding): surface unavailable-agent install cards in the Local CLI empty state (#4662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When onboarding's Local CLI step detected zero usable agents, the empty state showed only the `settings.noAgentsDetected` sentence + a Rescan button — none of the install cards, Install/Docs links, or per-agent diagnostics that Settings > Local CLI already provides. Extract the unavailable-agent install-card grid out of SettingsDialog into a shared `UnavailableAgentGrid` component and reuse it in both SettingsDialog and the onboarding empty state, so the two views stay in sync as agents are added. Settings behavior is unchanged (the component takes the AMR-attribution / external-open handlers as props); onboarding passes the unavailable agents it already has from the scan result. Adds a falsifiable regression test for the onboarding empty-state render path (cards + Install/Docs links + diagnostic row). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/EntryShell.tsx | 14 + apps/web/src/components/SettingsDialog.tsx | 99 +----- .../src/components/UnavailableAgentGrid.tsx | 184 ++++++++++ ...ell.onboarding-unavailable-agents.test.tsx | 318 ++++++++++++++++++ 4 files changed, 533 insertions(+), 82 deletions(-) create mode 100644 apps/web/src/components/UnavailableAgentGrid.tsx create mode 100644 apps/web/tests/components/EntryShell.onboarding-unavailable-agents.test.tsx diff --git a/apps/web/src/components/EntryShell.tsx b/apps/web/src/components/EntryShell.tsx index fc5e6555296..1ff093530a3 100644 --- a/apps/web/src/components/EntryShell.tsx +++ b/apps/web/src/components/EntryShell.tsx @@ -127,6 +127,7 @@ import { MODEL_COST_TIER_LABEL_KEYS, type ModelCapabilityTag, } from './modelCapabilityTags'; +import { UnavailableAgentGrid } from './UnavailableAgentGrid'; import { LanguageMenu } from './LanguageMenu'; import { IntegrationsView, type IntegrationTab } from './IntegrationsView'; import { InlineModelSwitcher } from './InlineModelSwitcher'; @@ -155,6 +156,7 @@ import type { KnownProvider } from '../state/config'; import { saveOnboardingProfile } from '../state/onboarding-profile'; import { testAgent, testApiProvider } from '../providers/connection-test'; import { fetchProviderModels } from '../providers/provider-models'; +import { openExternalUrl } from '../providers/registry'; import { cancelVelaLogin, fetchVelaLoginStatus, @@ -1411,6 +1413,7 @@ function OnboardingView({ provider.baseUrl === (config.apiProviderBaseUrl ?? config.baseUrl), ) ?? null; const availableCliAgents = agents.filter((agent) => agent.available && agent.id !== 'amr'); + const unavailableCliAgents = agents.filter((agent) => !agent.available && agent.id !== 'amr'); const visibleAgents = availableCliAgents.filter((agent) => visibleAgentIds.includes(agent.id)); const amrAgent = agents.find((agent) => agent.id === 'amr' && agent.available) ?? null; const amrSignedIn = amrStatus?.loggedIn === true; @@ -2647,6 +2650,7 @@ function OnboardingView({ {connectExpanded === 'local' ? ( {t('settings.noAgentsDetected')} + {unavailableAgents.length > 0 ? ( + {}} + onRescan={onRefresh} + onOpenFixUrl={(url) => void openExternalUrl(url)} + /> + ) : null} ) : null} {selectedAgent && modelOptions.length > 0 ? ( diff --git a/apps/web/src/components/SettingsDialog.tsx b/apps/web/src/components/SettingsDialog.tsx index 7875019ed23..13792ed7a24 100644 --- a/apps/web/src/components/SettingsDialog.tsx +++ b/apps/web/src/components/SettingsDialog.tsx @@ -39,6 +39,7 @@ import type { Locale } from '../i18n'; import type { Dict } from '../i18n/types'; import { AgentIcon } from './AgentIcon'; import { AgentDiagnosticRow } from './AgentDiagnosticRow'; +import { UnavailableAgentGrid } from './UnavailableAgentGrid'; import { AmrLoginPill } from './AmrLoginPill'; import { PlanBadge } from './PlanBadge'; import { orderAgentsWithOpenDesignFirst } from './agentOrdering'; @@ -4696,88 +4697,22 @@ export function SettingsDialog({ })} -
- {unavailableAgents.map((a) => { - const installUrl = sanitizeHttpsUrl(a.installUrl); - const docsUrl = sanitizeHttpsUrl(a.docsUrl); - const hasLinks = Boolean(installUrl || docsUrl); - const description = AGENT_SHORT_DESCRIPTIONS[a.id]; - const agentName = displayAgentName(a); - const diagnosticHandlers = diagnosticHandlersForAgent(a); - const cardLabel = `${agentName} · ${t('common.notInstalled')}`; - return ( -
-
- -
-
- {agentName} -
- {description ? ( -
- {description} -
- ) : null} -
- {hasLinks ? ( - - ) : null} -
- {/* Why is it unavailable? not-on-path vs a broken - shim vs a bad *_BIN override each get a - distinct, actionable line. It spans the full - card width on its own row below the - logo/name/links so it never crowds the inline - Docs/Install actions. */} - {(a.diagnostics ?? []).map((diagnostic, i) => ( - - ))} -
- ); - })} -
+ void handleRefreshAgents()} + onOpenFixUrl={(url, agent, kind) => + openAgentFixUrl( + url, + kind === 'install' && agent.id === 'amr' + ? 'settings_amr_install' + : undefined, + ) + } + attributeAmrInstallUrl={(url) => + attributedAmrSettingsUrl(url, 'settings_amr_install') + } + /> ) : null} {/* diff --git a/apps/web/src/components/UnavailableAgentGrid.tsx b/apps/web/src/components/UnavailableAgentGrid.tsx new file mode 100644 index 00000000000..2b0a7d7c285 --- /dev/null +++ b/apps/web/src/components/UnavailableAgentGrid.tsx @@ -0,0 +1,184 @@ +import { useT } from '../i18n'; +import type { AgentInfo } from '../types'; +import { AgentIcon } from './AgentIcon'; +import { AgentDiagnosticRow } from './AgentDiagnosticRow'; +import { Icon } from './Icon'; + +// Short, vendor-neutral one-liners shown under each unavailable agent's name. +// Duplicated (rather than imported) from SettingsDialog to keep this shared +// grid self-contained; the map is tiny and the host file follows the same +// "local copy" convention for these labels. +const AGENT_SHORT_DESCRIPTIONS: Record = { + claude: 'Anthropic official CLI', + codex: 'OpenAI official CLI', + 'cursor-agent': 'Cursor command line', + gemini: 'Google official CLI', + opencode: 'Open-source agent CLI', + qwen: 'Qwen coding CLI', + copilot: 'GitHub coding CLI', + devin: 'Cognition terminal CLI', + kimi: 'Moonshot Kimi CLI', + qoder: 'Alibaba coding CLI', + pi: 'Inflection chat CLI', + kiro: 'Kiro agent CLI', + kilo: 'Kilo Code CLI', + vibe: 'Mistral open-source CLI', + deepseek: 'DeepSeek terminal UI', + hermes: 'ACP agent CLI', + 'grok-build': 'xAI coding CLI', + reasonix: 'DeepSeek native coding CLI', +}; + +function sanitizeHttpsUrl(url: string | undefined): string | undefined { + if (!url) return undefined; + try { + const parsed = new URL(url); + return parsed.protocol === 'https:' ? parsed.toString() : undefined; + } catch { + return undefined; + } +} + +function displayAgentName(agent: Pick): string { + return agent.id === 'amr' ? 'Open Design AMR' : agent.name; +} + +export interface UnavailableAgentGridProps { + /** The unavailable agents to render as install cards. */ + agents: AgentInfo[]; + /** + * Called whenever the user clicks an Install/Docs affordance (inline link or + * diagnostic fix button). The Settings host uses this to arm a rescan for + * when the user returns to the app after installing; onboarding may pass a + * no-op. + */ + onInstallIntent: () => void; + /** Re-run agent detection (the "Rescan" affordance on diagnostic rows). */ + onRescan: () => void; + /** + * Open a fix URL (docs or install) from a diagnostic row's icon button. When + * omitted, the diagnostic buttons fall back to arming install intent only. + * The Settings host wires this to its `openAgentFixUrl` helper so AMR + * attribution + external-shell opening are preserved byte-for-byte. + */ + onOpenFixUrl?: (url: string, agent: AgentInfo, kind: 'docs' | 'install') => void; + /** + * Rewrite the inline Install anchor's href for the AMR agent so the handoff + * carries Settings attribution. Only the Settings host passes this; onboarding + * never renders AMR here, so it can omit it. + */ + attributeAmrInstallUrl?: (url: string) => string; +} + +/** + * The inner grid of "not installed" agent cards (icon + name + short + * description + Docs/Install links + per-diagnostic fix rows). Extracted from + * SettingsDialog so the onboarding empty-state can surface the same install + * affordances instead of a bare "no agents detected" sentence (issue #4662). + * + * Renders only the grid — callers wrap it (e.g. Settings keeps its + * `
` collapse) however they like. + */ +export function UnavailableAgentGrid({ + agents, + onInstallIntent, + onRescan, + onOpenFixUrl, + attributeAmrInstallUrl, +}: UnavailableAgentGridProps) { + const t = useT(); + return ( +
+ {agents.map((a) => { + const installUrl = sanitizeHttpsUrl(a.installUrl); + const docsUrl = sanitizeHttpsUrl(a.docsUrl); + const hasLinks = Boolean(installUrl || docsUrl); + const description = AGENT_SHORT_DESCRIPTIONS[a.id]; + const agentName = displayAgentName(a); + const diagnosticHandlers = { + onRescan, + ...(docsUrl + ? { + onOpenDocs: () => + onOpenFixUrl + ? onOpenFixUrl(docsUrl, a, 'docs') + : onInstallIntent(), + } + : {}), + ...(installUrl + ? { + onOpenInstall: () => + onOpenFixUrl + ? onOpenFixUrl(installUrl, a, 'install') + : onInstallIntent(), + } + : {}), + }; + const cardLabel = `${agentName} · ${t('common.notInstalled')}`; + return ( +
+
+ +
+
{agentName}
+ {description ? ( +
{description}
+ ) : null} +
+ {hasLinks ? ( + + ) : null} +
+ {/* Why is it unavailable? not-on-path vs a broken shim vs a bad + *_BIN override each get a distinct, actionable line. It spans the + full card width on its own row below the logo/name/links so it + never crowds the inline Docs/Install actions. */} + {(a.diagnostics ?? []).map((diagnostic, i) => ( + + ))} +
+ ); + })} +
+ ); +} diff --git a/apps/web/tests/components/EntryShell.onboarding-unavailable-agents.test.tsx b/apps/web/tests/components/EntryShell.onboarding-unavailable-agents.test.tsx new file mode 100644 index 00000000000..5ca16e4a5cf --- /dev/null +++ b/apps/web/tests/components/EntryShell.onboarding-unavailable-agents.test.tsx @@ -0,0 +1,318 @@ +// @vitest-environment jsdom +// +// Regression test for issue #4662: when the onboarding "Local CLI" step +// detects zero usable local coding agents, it must surface the same +// unavailable-agent install cards (Install/Docs links + diagnostics) that +// Settings shows — not just the bare `settings.noAgentsDetected` sentence. + +import { useState } from 'react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { EntryShell } from '../../src/components/EntryShell'; +import { I18nProvider } from '../../src/i18n'; +import type { AgentInfo, AppConfig } from '../../src/types'; + +const analyticsMocks = vi.hoisted(() => ({ + track: vi.fn(), +})); + +// Onboarding wires the grid's diagnostic fix-buttons to openExternalUrl (it has +// no AMR attribution to apply). Spy on it so we can assert the Install fix-button +// actually fires instead of resolving to a dead no-op (#4662 review follow-up). +const registryMocks = vi.hoisted(() => ({ + openExternalUrl: vi.fn(async () => true), +})); + +vi.mock('../../src/providers/registry', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + openExternalUrl: registryMocks.openExternalUrl, + }; +}); + +vi.mock('../../src/analytics/provider', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAnalytics: () => ({ + newRequestId: vi.fn(() => 'request-1'), + setConfigureGlobals: vi.fn(), + setConsent: vi.fn(), + setIdentity: vi.fn(), + track: analyticsMocks.track, + }), + useAppVersion: () => null, + }; +}); + +const originalFetch = globalThis.fetch; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +// An unavailable CLI agent carrying install/docs URLs plus a typed diagnostic +// with a rescan fix action — exactly the shape Settings renders as an install +// card with a fix-button row. +function unavailableCliAgent(overrides: Partial = {}): AgentInfo { + return { + id: 'gemini', + name: 'Gemini CLI', + bin: 'gemini', + available: false, + installUrl: 'https://example.com/install-gemini', + docsUrl: 'https://example.com/docs-gemini', + diagnostics: [ + { + reason: 'not-on-path', + severity: 'error', + message: 'Gemini CLI was not found on your PATH.', + fixActions: [{ kind: 'rescan' }], + }, + ], + ...overrides, + }; +} + +function baseConfig(overrides: Partial = {}): AppConfig { + return { + mode: 'daemon', + agentId: null, + agentModels: {}, + apiProtocol: 'anthropic', + apiProtocolConfigs: {}, + apiKey: '', + baseUrl: '', + model: '', + ...overrides, + } as AppConfig; +} + +function onboardingProps( + overrides: Partial> = {}, +): React.ComponentProps { + return { + skills: [], + designTemplates: [], + designSystems: [], + projects: [], + templates: [], + promptTemplates: [], + defaultDesignSystemId: null, + connectors: [], + connectorsLoading: false, + config: baseConfig(), + agents: [unavailableCliAgent()], + daemonLive: true, + onModeChange: vi.fn(), + onAgentChange: vi.fn(), + onAgentModelChange: vi.fn(), + onApiProtocolChange: vi.fn(), + onApiModelChange: vi.fn(), + onConfigPersist: vi.fn(), + onRefreshAgents: vi.fn(() => [unavailableCliAgent()]), + onThemeChange: vi.fn(), + onCreateProject: vi.fn(), + onCreatePluginShareProject: vi.fn(), + onImportClaudeDesign: vi.fn(), + onOpenProject: vi.fn(), + onOpenLiveArtifact: vi.fn(), + onDeleteProject: vi.fn(), + onRenameProject: vi.fn(), + onChangeDefaultDesignSystem: vi.fn(), + onPersistComposioKey: vi.fn(), + onOpenSettings: vi.fn(), + onCompleteOnboarding: vi.fn(), + ...overrides, + }; +} + +function renderOnboarding( + overrides: Partial> = {}, +) { + window.history.replaceState(null, '', '/onboarding'); + const props = onboardingProps(overrides); + + function Harness() { + const [config, setConfig] = useState(props.config); + return ( + + { + props.onConfigPersist(next); + setConfig(next as AppConfig); + }} + /> + + ); + } + + render(); + return props; +} + +afterEach(() => { + cleanup(); + globalThis.fetch = originalFetch; + vi.useRealTimers(); + analyticsMocks.track.mockReset(); + registryMocks.openExternalUrl.mockClear(); + window.sessionStorage.clear(); +}); + +beforeEach(() => { + globalThis.fetch = originalFetch; + analyticsMocks.track.mockReset(); + registryMocks.openExternalUrl.mockClear(); +}); + +describe('EntryShell onboarding Local CLI empty state', () => { + it('renders unavailable-agent install cards (Docs/Install links + diagnostic) when no usable agent is detected', async () => { + globalThis.fetch = vi.fn(async () => + jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' }), + ) as typeof fetch; + + renderOnboarding(); + + fireEvent.click(screen.getByRole('button', { name: /Local coding agent/i })); + + const localPanel = await waitFor(() => { + const panel = screen + .getByText('Local CLI') + .closest('.onboarding-view__setup-panel'); + if (!(panel instanceof HTMLElement)) throw new Error('local panel not found'); + // Wait until the scan has finished and the empty-state surfaced. + if (!panel.querySelector('.onboarding-view__empty-slice')) { + throw new Error('empty slice not rendered yet'); + } + return panel; + }); + + // The intro sentence is still there... + expect(localPanel.textContent).toContain('No agents detected yet.'); + + // ...but the regression fix adds the unavailable-agent install grid: + // the card, its Install + Docs links, and the diagnostic message must render. + const grid = localPanel.querySelector('.agent-grid-unavailable'); + expect(grid).toBeTruthy(); + + const installLink = localPanel.querySelector( + 'a[href="https://example.com/install-gemini"]', + ); + expect(installLink).toBeTruthy(); + expect(installLink?.textContent).toContain('Install'); + + const docsLink = localPanel.querySelector( + 'a[href="https://example.com/docs-gemini"]', + ); + expect(docsLink).toBeTruthy(); + + // The per-diagnostic actionable row is present. + expect(localPanel.textContent).toContain( + 'Gemini CLI was not found on your PATH.', + ); + expect( + localPanel.querySelector('[data-reason="not-on-path"]'), + ).toBeTruthy(); + }); + + it('excludes the AMR agent from the onboarding install grid', async () => { + globalThis.fetch = vi.fn(async () => + jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' }), + ) as typeof fetch; + + const amr = unavailableCliAgent({ + id: 'amr', + name: 'Open Design AMR', + installUrl: 'https://example.com/install-amr', + docsUrl: 'https://example.com/docs-amr', + }); + const gemini = unavailableCliAgent(); + renderOnboarding({ agents: [amr, gemini], onRefreshAgents: vi.fn(() => [amr, gemini]) }); + + fireEvent.click(screen.getByRole('button', { name: /Local coding agent/i })); + const localPanel = await waitFor(() => { + const panel = screen.getByText('Local CLI').closest('.onboarding-view__setup-panel'); + if (!(panel instanceof HTMLElement)) throw new Error('local panel not found'); + if (!panel.querySelector('.agent-grid-unavailable')) { + throw new Error('grid not rendered yet'); + } + return panel; + }); + + // A normal unavailable agent renders its install card... + expect( + localPanel.querySelector('a[href="https://example.com/install-gemini"]'), + ).toBeTruthy(); + // ...but AMR is filtered out — onboarding has its own AMR connect flow. + expect( + localPanel.querySelector('a[href="https://example.com/install-amr"]'), + ).toBeNull(); + expect(localPanel.textContent).not.toContain('Open Design AMR'); + }); + + it('shows only the empty-state sentence (no grid) when there are no unavailable agents', async () => { + globalThis.fetch = vi.fn(async () => + jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' }), + ) as typeof fetch; + + renderOnboarding({ agents: [], onRefreshAgents: vi.fn(() => []) }); + + fireEvent.click(screen.getByRole('button', { name: /Local coding agent/i })); + const localPanel = await waitFor(() => { + const panel = screen.getByText('Local CLI').closest('.onboarding-view__setup-panel'); + if (!(panel instanceof HTMLElement)) throw new Error('local panel not found'); + if (!panel.querySelector('.onboarding-view__empty-slice')) { + throw new Error('empty slice not rendered yet'); + } + return panel; + }); + + expect(localPanel.textContent).toContain('No agents detected yet.'); + expect(localPanel.querySelector('.agent-grid-unavailable')).toBeNull(); + }); + + it('wires the diagnostic Install fix-button to openExternalUrl', async () => { + globalThis.fetch = vi.fn(async () => + jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' }), + ) as typeof fetch; + + // A diagnostic carrying an `openInstall` fix action — before the fix this + // button rendered but did nothing in onboarding (no onOpenFixUrl wired). + const gemini = unavailableCliAgent({ + diagnostics: [ + { + reason: 'not-on-path', + severity: 'error', + message: 'Gemini CLI was not found on your PATH.', + fixActions: [{ kind: 'openInstall' }], + }, + ], + }); + renderOnboarding({ agents: [gemini], onRefreshAgents: vi.fn(() => [gemini]) }); + + fireEvent.click(screen.getByRole('button', { name: /Local coding agent/i })); + const localPanel = await waitFor(() => { + const panel = screen.getByText('Local CLI').closest('.onboarding-view__setup-panel'); + if (!(panel instanceof HTMLElement)) throw new Error('local panel not found'); + if (!panel.querySelector('[data-reason="not-on-path"] button')) { + throw new Error('diagnostic fix button not rendered yet'); + } + return panel; + }); + + const fixButton = localPanel.querySelector('[data-reason="not-on-path"] button'); + expect(fixButton).toBeTruthy(); + fireEvent.click(fixButton as HTMLElement); + expect(registryMocks.openExternalUrl).toHaveBeenCalledWith( + 'https://example.com/install-gemini', + ); + }); +});