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', + ); + }); +});