Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/web/src/components/EntryShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2647,6 +2650,7 @@ function OnboardingView({
{connectExpanded === 'local' ? (
<OnboardingCliSetupPanel
agents={visibleAgents}
unavailableAgents={unavailableCliAgents}
daemonLive={daemonLive}
selectedAgentId={config.agentId}
selectedAgent={selectedAgent}
Expand Down Expand Up @@ -2967,6 +2971,7 @@ function OnboardingView({

function OnboardingCliSetupPanel({
agents,
unavailableAgents,
daemonLive,
selectedAgentId,
selectedAgent,
Expand All @@ -2981,6 +2986,7 @@ function OnboardingCliSetupPanel({
onTest,
}: {
agents: AgentInfo[];
unavailableAgents: AgentInfo[];
daemonLive: boolean;
selectedAgentId: string | null;
selectedAgent: AgentInfo | null;
Expand Down Expand Up @@ -3061,6 +3067,14 @@ function OnboardingCliSetupPanel({
{showEmpty ? (
<div className="onboarding-view__empty-slice">
{t('settings.noAgentsDetected')}
{unavailableAgents.length > 0 ? (
<UnavailableAgentGrid
agents={unavailableAgents}
onInstallIntent={() => {}}
onRescan={onRefresh}
onOpenFixUrl={(url) => void openExternalUrl(url)}
/>
) : null}
</div>
) : null}
{selectedAgent && modelOptions.length > 0 ? (
Expand Down
99 changes: 17 additions & 82 deletions apps/web/src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -4696,88 +4697,22 @@ export function SettingsDialog({
})}
</span>
</summary>
<div className="agent-grid agent-grid-unavailable">
{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 (
<div
key={a.id}
className="agent-card disabled agent-card-unavailable"
role="group"
aria-label={cardLabel}
>
<div className="agent-card-unavailable-row">
<AgentIcon id={a.id} size={30} />
<div className="agent-card-body">
<div className="agent-card-name">
{agentName}
</div>
{description ? (
<div className="agent-card-description">
{description}
</div>
) : null}
</div>
{hasLinks ? (
<div className="agent-card-actions agent-card-actions--inline">
{docsUrl ? (
<a
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
className="agent-card-link agent-card-link--muted agent-card-link--icon"
onClick={markAgentInstallIntent}
title={t('settings.agentInstall.docs')}
aria-label={t('settings.agentInstall.docs')}
>
<Icon name="file" size={15} />
</a>
) : null}
{installUrl ? (
<a
href={installUrl}
target="_blank"
rel="noopener noreferrer"
className="agent-card-link agent-card-link--ghost"
onClick={(event) => {
markAgentInstallIntent();
if (a.id === 'amr') {
event.currentTarget.href = attributedAmrSettingsUrl(
installUrl,
'settings_amr_install',
);
}
}}
>
{t('settings.agentInstall.install')}
</a>
) : null}
</div>
) : null}
</div>
{/* 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) => (
<AgentDiagnosticRow
key={`${diagnostic.reason}-${i}`}
diagnostic={diagnostic}
handlers={diagnosticHandlers}
/>
))}
</div>
);
})}
</div>
<UnavailableAgentGrid
agents={unavailableAgents}
onInstallIntent={markAgentInstallIntent}
onRescan={() => void handleRefreshAgents()}
onOpenFixUrl={(url, agent, kind) =>
openAgentFixUrl(
url,
kind === 'install' && agent.id === 'amr'
? 'settings_amr_install'
: undefined,
)
}
attributeAmrInstallUrl={(url) =>
attributedAmrSettingsUrl(url, 'settings_amr_install')
}
/>
</details>
) : null}
{/*
Expand Down
184 changes: 184 additions & 0 deletions apps/web/src/components/UnavailableAgentGrid.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<AgentInfo, 'id' | 'name'>): 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
* `<details>` collapse) however they like.
*/
export function UnavailableAgentGrid({
agents,
onInstallIntent,
onRescan,
onOpenFixUrl,
attributeAmrInstallUrl,
}: UnavailableAgentGridProps) {
const t = useT();
return (
<div className="agent-grid agent-grid-unavailable">
{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 (
<div
key={a.id}
className="agent-card disabled agent-card-unavailable"
role="group"
aria-label={cardLabel}
>
<div className="agent-card-unavailable-row">
<AgentIcon id={a.id} size={30} />
<div className="agent-card-body">
<div className="agent-card-name">{agentName}</div>
{description ? (
<div className="agent-card-description">{description}</div>
) : null}
</div>
{hasLinks ? (
<div className="agent-card-actions agent-card-actions--inline">
{docsUrl ? (
<a
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
className="agent-card-link agent-card-link--muted agent-card-link--icon"
onClick={onInstallIntent}
title={t('settings.agentInstall.docs')}
aria-label={t('settings.agentInstall.docs')}
>
<Icon name="file" size={15} />
</a>
) : null}
{installUrl ? (
<a
href={installUrl}
target="_blank"
rel="noopener noreferrer"
className="agent-card-link agent-card-link--ghost"
onClick={(event) => {
onInstallIntent();
if (a.id === 'amr' && attributeAmrInstallUrl) {
event.currentTarget.href =
attributeAmrInstallUrl(installUrl);
}
}}
>
{t('settings.agentInstall.install')}
</a>
) : null}
</div>
) : null}
</div>
{/* 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) => (
<AgentDiagnosticRow
key={`${diagnostic.reason}-${i}`}
diagnostic={diagnostic}
handlers={diagnosticHandlers}
/>
))}
</div>
);
})}
</div>
);
}
Loading
Loading