Skip to content

Commit 51d9f9c

Browse files
maxmilianclaude
andcommitted
fix(onboarding): surface unavailable-agent install cards in the Local CLI empty state (#4662)
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) <noreply@anthropic.com>
1 parent e899fb9 commit 51d9f9c

4 files changed

Lines changed: 533 additions & 82 deletions

File tree

apps/web/src/components/EntryShell.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ import {
105105
import type { PluginUseAction } from './plugins-home/useActions';
106106
import { Icon } from './Icon';
107107
import { AgentIcon } from './AgentIcon';
108+
import { UnavailableAgentGrid } from './UnavailableAgentGrid';
108109
import { LanguageMenu } from './LanguageMenu';
109110
import { IntegrationsView, type IntegrationTab } from './IntegrationsView';
110111
import { InlineModelSwitcher } from './InlineModelSwitcher';
@@ -131,6 +132,7 @@ import type { KnownProvider } from '../state/config';
131132
import { saveOnboardingProfile } from '../state/onboarding-profile';
132133
import { testApiProvider } from '../providers/connection-test';
133134
import { fetchProviderModels } from '../providers/provider-models';
135+
import { openExternalUrl } from '../providers/registry';
134136
import {
135137
cancelVelaLogin,
136138
fetchVelaLoginStatus,
@@ -1157,6 +1159,7 @@ function OnboardingView({
11571159
provider.baseUrl === (config.apiProviderBaseUrl ?? config.baseUrl),
11581160
) ?? null;
11591161
const availableCliAgents = agents.filter((agent) => agent.available && agent.id !== 'amr');
1162+
const unavailableCliAgents = agents.filter((agent) => !agent.available && agent.id !== 'amr');
11601163
const visibleAgents = availableCliAgents.filter((agent) => visibleAgentIds.includes(agent.id));
11611164
const amrAgent = agents.find((agent) => agent.id === 'amr' && agent.available) ?? null;
11621165
const amrSignedIn = amrStatus?.loggedIn === true;
@@ -2349,6 +2352,7 @@ function OnboardingView({
23492352
{connectExpanded === 'local' ? (
23502353
<OnboardingCliSetupPanel
23512354
agents={visibleAgents}
2355+
unavailableAgents={unavailableCliAgents}
23522356
daemonLive={daemonLive}
23532357
selectedAgentId={config.agentId}
23542358
selectedAgent={selectedAgent}
@@ -2678,6 +2682,7 @@ function OnboardingView({
26782682

26792683
function OnboardingCliSetupPanel({
26802684
agents,
2685+
unavailableAgents,
26812686
daemonLive,
26822687
selectedAgentId,
26832688
selectedAgent,
@@ -2689,6 +2694,7 @@ function OnboardingCliSetupPanel({
26892694
onSelectModel,
26902695
}: {
26912696
agents: AgentInfo[];
2697+
unavailableAgents: AgentInfo[];
26922698
daemonLive: boolean;
26932699
selectedAgentId: string | null;
26942700
selectedAgent: AgentInfo | null;
@@ -2754,6 +2760,14 @@ function OnboardingCliSetupPanel({
27542760
{showEmpty ? (
27552761
<div className="onboarding-view__empty-slice">
27562762
{t('settings.noAgentsDetected')}
2763+
{unavailableAgents.length > 0 ? (
2764+
<UnavailableAgentGrid
2765+
agents={unavailableAgents}
2766+
onInstallIntent={() => {}}
2767+
onRescan={onRefresh}
2768+
onOpenFixUrl={(url) => void openExternalUrl(url)}
2769+
/>
2770+
) : null}
27572771
</div>
27582772
) : null}
27592773
{selectedAgent && modelOptions.length > 0 ? (

apps/web/src/components/SettingsDialog.tsx

Lines changed: 17 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import type { Locale } from '../i18n';
3939
import type { Dict } from '../i18n/types';
4040
import { AgentIcon } from './AgentIcon';
4141
import { AgentDiagnosticRow } from './AgentDiagnosticRow';
42+
import { UnavailableAgentGrid } from './UnavailableAgentGrid';
4243
import { AmrLoginPill } from './AmrLoginPill';
4344
import {
4445
AMR_LOGIN_STATUS_EVENT,
@@ -4071,88 +4072,22 @@ export function SettingsDialog({
40714072
})}
40724073
</span>
40734074
</summary>
4074-
<div className="agent-grid agent-grid-unavailable">
4075-
{unavailableAgents.map((a) => {
4076-
const installUrl = sanitizeHttpsUrl(a.installUrl);
4077-
const docsUrl = sanitizeHttpsUrl(a.docsUrl);
4078-
const hasLinks = Boolean(installUrl || docsUrl);
4079-
const description = AGENT_SHORT_DESCRIPTIONS[a.id];
4080-
const agentName = displayAgentName(a);
4081-
const diagnosticHandlers = diagnosticHandlersForAgent(a);
4082-
const cardLabel = `${agentName} · ${t('common.notInstalled')}`;
4083-
return (
4084-
<div
4085-
key={a.id}
4086-
className="agent-card disabled agent-card-unavailable"
4087-
role="group"
4088-
aria-label={cardLabel}
4089-
>
4090-
<div className="agent-card-unavailable-row">
4091-
<AgentIcon id={a.id} size={30} />
4092-
<div className="agent-card-body">
4093-
<div className="agent-card-name">
4094-
{agentName}
4095-
</div>
4096-
{description ? (
4097-
<div className="agent-card-description">
4098-
{description}
4099-
</div>
4100-
) : null}
4101-
</div>
4102-
{hasLinks ? (
4103-
<div className="agent-card-actions agent-card-actions--inline">
4104-
{docsUrl ? (
4105-
<a
4106-
href={docsUrl}
4107-
target="_blank"
4108-
rel="noopener noreferrer"
4109-
className="agent-card-link agent-card-link--muted agent-card-link--icon"
4110-
onClick={markAgentInstallIntent}
4111-
title={t('settings.agentInstall.docs')}
4112-
aria-label={t('settings.agentInstall.docs')}
4113-
>
4114-
<Icon name="file" size={15} />
4115-
</a>
4116-
) : null}
4117-
{installUrl ? (
4118-
<a
4119-
href={installUrl}
4120-
target="_blank"
4121-
rel="noopener noreferrer"
4122-
className="agent-card-link agent-card-link--ghost"
4123-
onClick={(event) => {
4124-
markAgentInstallIntent();
4125-
if (a.id === 'amr') {
4126-
event.currentTarget.href = attributedAmrSettingsUrl(
4127-
installUrl,
4128-
'settings_amr_install',
4129-
);
4130-
}
4131-
}}
4132-
>
4133-
{t('settings.agentInstall.install')}
4134-
</a>
4135-
) : null}
4136-
</div>
4137-
) : null}
4138-
</div>
4139-
{/* Why is it unavailable? not-on-path vs a broken
4140-
shim vs a bad *_BIN override each get a
4141-
distinct, actionable line. It spans the full
4142-
card width on its own row below the
4143-
logo/name/links so it never crowds the inline
4144-
Docs/Install actions. */}
4145-
{(a.diagnostics ?? []).map((diagnostic, i) => (
4146-
<AgentDiagnosticRow
4147-
key={`${diagnostic.reason}-${i}`}
4148-
diagnostic={diagnostic}
4149-
handlers={diagnosticHandlers}
4150-
/>
4151-
))}
4152-
</div>
4153-
);
4154-
})}
4155-
</div>
4075+
<UnavailableAgentGrid
4076+
agents={unavailableAgents}
4077+
onInstallIntent={markAgentInstallIntent}
4078+
onRescan={() => void handleRefreshAgents()}
4079+
onOpenFixUrl={(url, agent, kind) =>
4080+
openAgentFixUrl(
4081+
url,
4082+
kind === 'install' && agent.id === 'amr'
4083+
? 'settings_amr_install'
4084+
: undefined,
4085+
)
4086+
}
4087+
attributeAmrInstallUrl={(url) =>
4088+
attributedAmrSettingsUrl(url, 'settings_amr_install')
4089+
}
4090+
/>
41564091
</details>
41574092
) : null}
41584093
{/*
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { useT } from '../i18n';
2+
import type { AgentInfo } from '../types';
3+
import { AgentIcon } from './AgentIcon';
4+
import { AgentDiagnosticRow } from './AgentDiagnosticRow';
5+
import { Icon } from './Icon';
6+
7+
// Short, vendor-neutral one-liners shown under each unavailable agent's name.
8+
// Duplicated (rather than imported) from SettingsDialog to keep this shared
9+
// grid self-contained; the map is tiny and the host file follows the same
10+
// "local copy" convention for these labels.
11+
const AGENT_SHORT_DESCRIPTIONS: Record<string, string> = {
12+
claude: 'Anthropic official CLI',
13+
codex: 'OpenAI official CLI',
14+
'cursor-agent': 'Cursor command line',
15+
gemini: 'Google official CLI',
16+
opencode: 'Open-source agent CLI',
17+
qwen: 'Qwen coding CLI',
18+
copilot: 'GitHub coding CLI',
19+
devin: 'Cognition terminal CLI',
20+
kimi: 'Moonshot Kimi CLI',
21+
qoder: 'Alibaba coding CLI',
22+
pi: 'Inflection chat CLI',
23+
kiro: 'Kiro agent CLI',
24+
kilo: 'Kilo Code CLI',
25+
vibe: 'Mistral open-source CLI',
26+
deepseek: 'DeepSeek terminal UI',
27+
hermes: 'ACP agent CLI',
28+
'grok-build': 'xAI coding CLI',
29+
reasonix: 'DeepSeek native coding CLI',
30+
};
31+
32+
function sanitizeHttpsUrl(url: string | undefined): string | undefined {
33+
if (!url) return undefined;
34+
try {
35+
const parsed = new URL(url);
36+
return parsed.protocol === 'https:' ? parsed.toString() : undefined;
37+
} catch {
38+
return undefined;
39+
}
40+
}
41+
42+
function displayAgentName(agent: Pick<AgentInfo, 'id' | 'name'>): string {
43+
return agent.id === 'amr' ? 'Open Design AMR' : agent.name;
44+
}
45+
46+
export interface UnavailableAgentGridProps {
47+
/** The unavailable agents to render as install cards. */
48+
agents: AgentInfo[];
49+
/**
50+
* Called whenever the user clicks an Install/Docs affordance (inline link or
51+
* diagnostic fix button). The Settings host uses this to arm a rescan for
52+
* when the user returns to the app after installing; onboarding may pass a
53+
* no-op.
54+
*/
55+
onInstallIntent: () => void;
56+
/** Re-run agent detection (the "Rescan" affordance on diagnostic rows). */
57+
onRescan: () => void;
58+
/**
59+
* Open a fix URL (docs or install) from a diagnostic row's icon button. When
60+
* omitted, the diagnostic buttons fall back to arming install intent only.
61+
* The Settings host wires this to its `openAgentFixUrl` helper so AMR
62+
* attribution + external-shell opening are preserved byte-for-byte.
63+
*/
64+
onOpenFixUrl?: (url: string, agent: AgentInfo, kind: 'docs' | 'install') => void;
65+
/**
66+
* Rewrite the inline Install anchor's href for the AMR agent so the handoff
67+
* carries Settings attribution. Only the Settings host passes this; onboarding
68+
* never renders AMR here, so it can omit it.
69+
*/
70+
attributeAmrInstallUrl?: (url: string) => string;
71+
}
72+
73+
/**
74+
* The inner grid of "not installed" agent cards (icon + name + short
75+
* description + Docs/Install links + per-diagnostic fix rows). Extracted from
76+
* SettingsDialog so the onboarding empty-state can surface the same install
77+
* affordances instead of a bare "no agents detected" sentence (issue #4662).
78+
*
79+
* Renders only the grid — callers wrap it (e.g. Settings keeps its
80+
* `<details>` collapse) however they like.
81+
*/
82+
export function UnavailableAgentGrid({
83+
agents,
84+
onInstallIntent,
85+
onRescan,
86+
onOpenFixUrl,
87+
attributeAmrInstallUrl,
88+
}: UnavailableAgentGridProps) {
89+
const t = useT();
90+
return (
91+
<div className="agent-grid agent-grid-unavailable">
92+
{agents.map((a) => {
93+
const installUrl = sanitizeHttpsUrl(a.installUrl);
94+
const docsUrl = sanitizeHttpsUrl(a.docsUrl);
95+
const hasLinks = Boolean(installUrl || docsUrl);
96+
const description = AGENT_SHORT_DESCRIPTIONS[a.id];
97+
const agentName = displayAgentName(a);
98+
const diagnosticHandlers = {
99+
onRescan,
100+
...(docsUrl
101+
? {
102+
onOpenDocs: () =>
103+
onOpenFixUrl
104+
? onOpenFixUrl(docsUrl, a, 'docs')
105+
: onInstallIntent(),
106+
}
107+
: {}),
108+
...(installUrl
109+
? {
110+
onOpenInstall: () =>
111+
onOpenFixUrl
112+
? onOpenFixUrl(installUrl, a, 'install')
113+
: onInstallIntent(),
114+
}
115+
: {}),
116+
};
117+
const cardLabel = `${agentName} · ${t('common.notInstalled')}`;
118+
return (
119+
<div
120+
key={a.id}
121+
className="agent-card disabled agent-card-unavailable"
122+
role="group"
123+
aria-label={cardLabel}
124+
>
125+
<div className="agent-card-unavailable-row">
126+
<AgentIcon id={a.id} size={30} />
127+
<div className="agent-card-body">
128+
<div className="agent-card-name">{agentName}</div>
129+
{description ? (
130+
<div className="agent-card-description">{description}</div>
131+
) : null}
132+
</div>
133+
{hasLinks ? (
134+
<div className="agent-card-actions agent-card-actions--inline">
135+
{docsUrl ? (
136+
<a
137+
href={docsUrl}
138+
target="_blank"
139+
rel="noopener noreferrer"
140+
className="agent-card-link agent-card-link--muted agent-card-link--icon"
141+
onClick={onInstallIntent}
142+
title={t('settings.agentInstall.docs')}
143+
aria-label={t('settings.agentInstall.docs')}
144+
>
145+
<Icon name="file" size={15} />
146+
</a>
147+
) : null}
148+
{installUrl ? (
149+
<a
150+
href={installUrl}
151+
target="_blank"
152+
rel="noopener noreferrer"
153+
className="agent-card-link agent-card-link--ghost"
154+
onClick={(event) => {
155+
onInstallIntent();
156+
if (a.id === 'amr' && attributeAmrInstallUrl) {
157+
event.currentTarget.href =
158+
attributeAmrInstallUrl(installUrl);
159+
}
160+
}}
161+
>
162+
{t('settings.agentInstall.install')}
163+
</a>
164+
) : null}
165+
</div>
166+
) : null}
167+
</div>
168+
{/* Why is it unavailable? not-on-path vs a broken shim vs a bad
169+
*_BIN override each get a distinct, actionable line. It spans the
170+
full card width on its own row below the logo/name/links so it
171+
never crowds the inline Docs/Install actions. */}
172+
{(a.diagnostics ?? []).map((diagnostic, i) => (
173+
<AgentDiagnosticRow
174+
key={`${diagnostic.reason}-${i}`}
175+
diagnostic={diagnostic}
176+
handlers={diagnosticHandlers}
177+
/>
178+
))}
179+
</div>
180+
);
181+
})}
182+
</div>
183+
);
184+
}

0 commit comments

Comments
 (0)