open-design/apps/web/src/components/EntryShell.tsx
BayesWang af4a62b69a
Add configurable project locations (#2041)
* add daemon project location support

* wire project locations into web settings

* localize project location settings

* move default project location to settings

* polish project location selection cards

* fix project location i18n gaps

* fix external project validation cleanup
2026-05-31 04:47:45 +00:00

2718 lines
97 KiB
TypeScript

// EntryShell — the centered-hero entry layout.
//
// This component owns the entire JSX render and local UI state for
// the redesigned home view (left rail + sticky settings cog + hero +
// recent projects + plugins section + new-project modal). It is
// intentionally a sibling of `EntryView` so that upstream `main`
// changes to `EntryView` (props, connector lifecycle, helpers, exports)
// can be rebased without touching this file. `EntryView` becomes a
// thin wrapper that passes data and callbacks through to this shell.
import {
useEffect,
useMemo,
useRef,
useState,
type Dispatch,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
type SetStateAction,
} from 'react';
import {
defaultScenarioPluginIdForProjectMetadata,
type ConnectorDetail,
type InstalledPluginRecord,
} from '@open-design/contracts';
import type { OpenDesignHostProjectImportSuccess } from '@open-design/host';
import type { DesignSystemGenerateSnapshot } from './DesignSystemFlow';
import { useAnalytics } from '../analytics/provider';
import {
trackHomeNavClick,
trackHomeToolbarClick,
trackOnboardingClick,
trackOnboardingCompleteResult,
trackOnboardingRuntimeScanResult,
trackPageView,
} from '../analytics/events';
import {
clearOnboardingSessionId,
getOrCreateOnboardingSessionId,
} from '../analytics/onboarding-session';
import type {
TrackingOnboardingArea,
TrackingOnboardingStepIndex,
TrackingOnboardingStepName,
TrackingOnboardingClickElement,
TrackingOnboardingClickAction,
TrackingOnboardingRuntimeType,
TrackingOnboardingCompletionResult,
TrackingOnboardingCompletionType,
TrackingCliProviderId,
} from '@open-design/contracts/analytics';
import { agentIdToTracking } from '@open-design/contracts/analytics';
import { useT } from '../i18n';
import { navigate, useRoute } from '../router';
import type {
AgentInfo,
ApiProtocol,
ApiProtocolConfig,
AppConfig,
AppTheme,
ConnectionTestResponse,
DesignSystemSummary,
ExecMode,
Project,
ProjectMetadata,
ProjectTemplate,
PromptTemplateSummary,
ProviderModelOption,
ProviderModelsResponse,
SkillSummary,
} from '../types';
import { CenteredLoader } from './Loading';
import { DesignsTab } from './DesignsTab';
import { DesignSystemPreviewModal } from './DesignSystemPreviewModal';
import { DesignSystemsTab } from './DesignSystemsTab';
import { EntryNavRail, type EntryView as EntryViewKind } from './EntryNavRail';
import { UpdaterPopup } from './UpdaterPopup';
import { GithubStarBadge } from './GithubStarBadge';
import { HomeView } from './HomeView';
import {
createPluginAuthoringHandoff,
createPluginUseHandoff,
type HomePromptHandoff,
} from './home-hero/plugin-authoring';
import type { PluginUseAction } from './plugins-home/useActions';
import { Icon } from './Icon';
import { AgentIcon } from './AgentIcon';
import { IntegrationsView, type IntegrationTab } from './IntegrationsView';
import { InlineModelSwitcher } from './InlineModelSwitcher';
import { NewProjectModal } from './NewProjectModal';
import { PluginsView } from './PluginsView';
import type { CreateInput, CreateTab, ImportClaudeDesignOutcome } from './NewProjectPanel';
import type { PluginLoopSubmit } from './PluginLoopHome';
import type {
PluginShareAction,
PluginShareProjectOutcome,
} from '../state/projects';
import { TasksView } from './TasksView';
import {
API_KEY_PLACEHOLDERS,
API_PROTOCOL_TABS,
SUGGESTED_MODELS_BY_PROTOCOL,
} from '../state/apiProtocols';
import { KNOWN_PROVIDERS } from '../state/config';
import type { KnownProvider } from '../state/config';
import { testApiProvider } from '../providers/connection-test';
import { fetchProviderModels } from '../providers/provider-models';
import {
cancelVelaLogin,
fetchVelaLoginStatus,
startVelaLogin,
type VelaLoginStatus,
} from '../providers/daemon';
import {
AMR_LOGIN_POLL_INTERVAL_MS,
amrLoginPollOutcome,
} from './amrLoginPolling';
import { renderModelOptions } from './modelOptions';
// The topbar chips (GitHub star, model switcher, Use everywhere)
// collapse into the settings dropdown when the viewport gets
// narrow. The transition is driven entirely by CSS @media queries
// in `entry-layout.css` so server and client render identical
// markup — both surfaces are always present, and CSS toggles
// `display` based on `--compact-topbar` breakpoint (900px).
// Default scenario plugin for each project kind/intent. The mapping
// lives in `@open-design/contracts` so the daemon's `/api/projects`
// and `/api/runs` fallbacks resolve to the same plugin id when no
// `pluginId` is on the request body — plan §3.3 of
// `specs/current/plugin-driven-flow-plan.md`.
const ONBOARDING_AMR_MODEL_OPTIONS: NonNullable<AgentInfo['models']> = [
{ id: 'claude-opus-4.8', label: 'Claude Opus 4.8' },
{ id: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash' },
{ id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
{ id: 'glm-5.1', label: 'GLM 5.1' },
];
function defaultPluginIdForMetadata(metadata: ProjectMetadata): string | null {
return defaultScenarioPluginIdForProjectMetadata(metadata);
}
function defaultPluginInputsForCreate(
input: CreateInput,
pluginId: string | null,
): Record<string, unknown> | null {
const kind = input.metadata.kind;
const projectName = input.name.trim();
if (pluginId === 'example-web-prototype') {
return {
artifactKind: input.metadata.includeLandingPage
? 'landing page'
: 'web prototype',
fidelity: input.metadata.fidelity ?? 'high-fidelity',
audience: 'product evaluators',
designSystem: 'the active project design system',
template: input.metadata.templateLabel ?? 'the bundled web prototype seed',
};
}
if (pluginId === 'example-simple-deck') {
return {
deckType: 'pitch deck',
topic: projectName || 'the user brief',
audience: 'decision makers',
slideCount: '10-15 pages',
speakerNotes: input.metadata.speakerNotes
? 'include speaker notes'
: 'no speaker notes',
designSystem: 'the active project design system',
};
}
if (pluginId === 'od-new-generation') {
const templateLabel = input.metadata.templateLabel?.trim();
const artifactKind =
kind === 'template'
? 'artifact based on a saved template'
: kind === 'other'
? 'custom design artifact'
: `${kind} artifact`;
return {
artifactKind,
audience: 'product and design reviewers',
topic: templateLabel || projectName || 'the user brief',
};
}
if (pluginId !== 'od-media-generation') return null;
if (kind !== 'image' && kind !== 'video' && kind !== 'audio') return null;
const promptTemplate = input.metadata.promptTemplate;
const subject =
promptTemplate?.prompt?.trim()
|| projectName
|| promptTemplate?.title?.trim()
|| `${kind} concept`;
const style =
promptTemplate?.summary?.trim()
|| 'cinematic, high-quality, on-brand';
const aspect =
kind === 'image'
? input.metadata.imageAspect
: kind === 'video'
? input.metadata.videoAspect
: undefined;
return {
mediaKind: kind,
subject,
style,
...(aspect ? { aspect } : {}),
};
}
// Theme options exposed in the avatar-popover appearance submenu.
interface Props {
skills: SkillSummary[];
designTemplates: SkillSummary[];
designSystems: DesignSystemSummary[];
projects: Project[];
templates: ProjectTemplate[];
onDeleteTemplate?: (id: string) => Promise<boolean>;
promptTemplates: PromptTemplateSummary[];
defaultDesignSystemId: string | null;
connectors: ConnectorDetail[];
connectorsLoading: boolean;
integrationInitialTab?: IntegrationTab;
composioConfigLoading?: boolean;
skillsLoading?: boolean;
designSystemsLoading?: boolean;
projectsLoading?: boolean;
// Execution / model-switching context. Threaded down from `App` so the
// top-bar `InlineModelSwitcher` can render the active mode/agent/model
// and persist changes through the same callbacks the project view uses.
config: AppConfig;
providerModelsCache?: Record<string, ProviderModelOption[]>;
onProviderModelsCacheChange?: Dispatch<SetStateAction<Record<string, ProviderModelOption[]>>>;
agents: AgentInfo[];
daemonLive: boolean;
onModeChange: (mode: ExecMode) => void;
onAgentChange: (id: string) => void;
onAgentModelChange: (
id: string,
choice: { model?: string; reasoning?: string },
) => void;
onApiProtocolChange: (protocol: ApiProtocol) => void;
onApiModelChange: (model: string) => void;
onConfigPersist: (cfg: AppConfig) => Promise<void> | void;
onRefreshAgents: () => Promise<AgentInfo[]> | AgentInfo[];
// Quick theme switch from the avatar-popover dropdown. Lets the user
// flip between system / light / dark without opening the full Settings
// dialog. App owns persistence; this component just calls the callback.
onThemeChange: (theme: AppTheme) => void;
onCreateProject: (
input: CreateInput & {
pendingPrompt?: string;
pluginId?: string;
appliedPluginSnapshotId?: string;
pluginInputs?: Record<string, unknown>;
autoSendFirstMessage?: boolean;
pendingFiles?: File[];
},
) => Promise<boolean> | boolean | void;
onCreatePluginShareProject: (
pluginId: string,
action: PluginShareAction,
locale?: string,
) => Promise<PluginShareProjectOutcome>;
onImportClaudeDesign: (
file: File,
) => Promise<ImportClaudeDesignOutcome | void> | ImportClaudeDesignOutcome | void;
onImportFolder?: (baseDir: string) => Promise<void> | void;
onImportFolderResponse?: (response: OpenDesignHostProjectImportSuccess) => Promise<void> | void;
onOpenProject: (id: string) => void;
onOpenLiveArtifact: (projectId: string, artifactId: string) => void;
onDeleteProject: (id: string) => Promise<boolean | void> | boolean | void;
onRenameProject: (id: string, name: string) => void;
onChangeDefaultDesignSystem: (id: string) => void;
onCreateDesignSystem?: () => void;
renderDesignSystemCreation?: (
onBack: () => void,
hooks?: {
onBeforeGenerate?: (snapshot: DesignSystemGenerateSnapshot) => void;
onGenerateSettled?: (
snapshot: DesignSystemGenerateSnapshot,
outcome:
| { result: 'success' }
| { result: 'failed'; errorCode: string },
) => void;
},
) => ReactNode;
onOpenDesignSystem?: (id: string) => void;
onDesignSystemsRefresh?: () => Promise<void> | void;
onPersistComposioKey: (composio: AppConfig['composio']) => Promise<void> | void;
onOpenSettings: (
section?:
| 'execution'
| 'media'
| 'composio'
| 'orbit'
| 'integrations'
| 'mcpClient'
| 'language'
| 'appearance'
| 'notifications'
| 'pet'
| 'projectLocations'
| 'library'
| 'about'
| 'memory'
| 'designSystems',
) => void;
onCompleteOnboarding: () => void;
}
// Map an EntryNavRail view id to the analytics `element` enum on
// `home/nav` ui_click. Returns `null` for views without a dedicated nav
// button (the rail's "Home" target is the brand logo, which gets its own
// element value via the logo click handler — not the changeView path).
function navElementForView(
next: EntryViewKind,
):
| 'home'
| 'projects'
| 'automations'
| 'plugins'
| 'design_systems'
| 'integrations'
| null {
switch (next) {
case 'home':
return 'home';
case 'projects':
return 'projects';
case 'tasks':
return 'automations';
case 'plugins':
return 'plugins';
case 'design-systems':
return 'design_systems';
case 'integrations':
return 'integrations';
default:
return null;
}
}
export function EntryShell({
skills,
designTemplates,
designSystems,
projects,
templates,
onDeleteTemplate,
promptTemplates,
defaultDesignSystemId,
connectors,
connectorsLoading,
integrationInitialTab = 'mcp',
composioConfigLoading = false,
skillsLoading = false,
designSystemsLoading = false,
projectsLoading = false,
config,
providerModelsCache: sharedProviderModelsCache,
onProviderModelsCacheChange,
agents,
daemonLive,
onModeChange,
onAgentChange,
onAgentModelChange,
onApiProtocolChange,
onApiModelChange,
onConfigPersist,
onRefreshAgents,
onThemeChange,
onCreateProject,
onCreatePluginShareProject,
onImportClaudeDesign,
onImportFolder,
onImportFolderResponse,
onOpenProject,
onOpenLiveArtifact,
onDeleteProject,
onRenameProject,
onChangeDefaultDesignSystem,
onCreateDesignSystem,
renderDesignSystemCreation,
onOpenDesignSystem,
onDesignSystemsRefresh,
onPersistComposioKey,
onOpenSettings,
onCompleteOnboarding,
}: Props) {
const t = useT();
// Each entry sub-view (home / projects / design-systems) is its own
// URL now, so the browser back/forward buttons work and a deep link
// to /design-systems lands on that section. We derive the active
// view from the route rather than keeping it in component state.
const route = useRoute();
const view: EntryViewKind = route.kind === 'home' ? route.view : 'home';
const [previewSystemId, setPreviewSystemId] = useState<string | null>(null);
const [newProjectOpen, setNewProjectOpen] = useState(false);
const [newProjectInitialTab, setNewProjectInitialTab] =
useState<CreateTab>('prototype');
const [integrationTab, setIntegrationTab] = useState<IntegrationTab>(integrationInitialTab);
const [homePromptHandoff, setHomePromptHandoff] = useState<HomePromptHandoff | null>(null);
const analytics = useAnalytics();
function changeView(next: EntryViewKind) {
const navElement = navElementForView(next);
if (navElement) {
trackHomeNavClick(analytics.track, {
page_name: 'home',
area: 'nav',
element: navElement,
});
}
navigate({ kind: 'home', view: next });
}
function startPluginAuthoring(goal?: string) {
setHomePromptHandoff(
createPluginAuthoringHandoff(Date.now(), goal),
);
changeView('home');
}
function usePluginFromLibrary(
record: InstalledPluginRecord,
action: PluginUseAction = 'use',
) {
setHomePromptHandoff(
createPluginUseHandoff(Date.now(), record.id, { action }),
);
changeView('home');
}
useEffect(() => {
setIntegrationTab(integrationInitialTab);
}, [integrationInitialTab]);
function openIntegrationTab(tab: IntegrationTab) {
setIntegrationTab(tab);
changeView('integrations');
}
function openNewProject(tab: CreateTab = 'prototype') {
setNewProjectInitialTab(tab);
setNewProjectOpen(true);
}
const previewSystem = useMemo(
() => (previewSystemId ? designSystems.find((d) => d.id === previewSystemId) ?? null : null),
[designSystems, previewSystemId],
);
function handleCreate(input: CreateInput) {
// The NewProjectModal no longer asks the user to pick a plugin.
// Each project kind is silently bound to its default scenario
// pipeline at creation time so the user lands in a running flow
// without having to reason about pipeline internals. The mapping
// is intentionally explicit so future kind-specific scenarios
// (e.g. a deck- or image-specialized pipeline) can take over a
// single row without touching the form.
const pluginId = defaultPluginIdForMetadata(input.metadata);
const pluginInputs = defaultPluginInputsForCreate(input, pluginId);
return onCreateProject({
...input,
...(pluginId ? { pluginId } : {}),
...(pluginInputs ? { pluginInputs } : {}),
});
}
// Plan §3.F5 — the home prompt-loop submit path. The user picks a
// plugin (which calls /api/plugins/:id/apply and binds a snapshot),
// edits the rendered example query if any, then presses Enter. We
// derive a project name from the active plugin (or prompt head),
// forward the pluginId so POST /api/projects pins the snapshot to
// project + conversation, and request auto-send of the first
// message so the user lands inside a running pipeline.
//
// Stage B of plugin-driven-flow-plan: the rail can stamp a
// `projectKind` on the payload so the created project records the
// chosen surface (image / video / audio, etc.). Free-form Home
// submits now arrive with the hidden od-default router plugin and
// projectKind='other', so the agent asks for the exact task type
// before continuing.
function handlePluginLoopSubmit(payload: PluginLoopSubmit) {
const head = payload.prompt.trim().split(/\s+/).slice(0, 8).join(' ');
const firstAttachmentName = payload.attachments?.[0]?.name ?? '';
const fallbackName = head.length > 0 ? head : firstAttachmentName || 'Untitled';
const name =
payload.pluginTitle && payload.pluginTitle.trim().length > 0
? payload.pluginTitle.trim()
: fallbackName;
const metadata: ProjectMetadata = {
...(payload.projectMetadata ?? {}),
kind: payload.projectKind ?? payload.projectMetadata?.kind ?? 'prototype',
nameSource: 'prompt',
...(payload.contextPlugins && payload.contextPlugins.length > 0
? { contextPlugins: payload.contextPlugins }
: {}),
...(payload.contextMcpServers && payload.contextMcpServers.length > 0
? { contextMcpServers: payload.contextMcpServers }
: {}),
...(payload.contextConnectors && payload.contextConnectors.length > 0
? { contextConnectors: payload.contextConnectors }
: {}),
...(payload.workingDir ? { userWorkingDir: payload.workingDir } : {}),
};
onCreateProject({
name,
skillId: payload.skillId ?? null,
designSystemId: payload.designSystemId ?? null,
metadata,
pendingPrompt: payload.prompt,
...(payload.pluginId ? { pluginId: payload.pluginId } : {}),
...(payload.appliedPluginSnapshotId
? { appliedPluginSnapshotId: payload.appliedPluginSnapshotId }
: {}),
...(payload.pluginInputs ? { pluginInputs: payload.pluginInputs } : {}),
...(payload.attachments && payload.attachments.length > 0
? { pendingFiles: payload.attachments }
: {}),
autoSendFirstMessage: true,
});
}
function finishOnboarding() {
onCompleteOnboarding();
changeView('home');
}
const avatarMenu = (
<button
type="button"
className="settings-icon-btn"
onClick={() => onOpenSettings()}
title={t('entry.openSettingsTitle')}
aria-label={t('entry.openSettingsAria')}
>
<Icon name="settings" size={17} />
</button>
);
if (view === 'onboarding') {
return (
<div className="entry-shell entry-shell--no-header entry-shell--onboarding">
<main className="entry-onboarding-modal" aria-label={t('settings.welcomeTitle')}>
<OnboardingView
config={config}
agents={agents}
daemonLive={daemonLive}
onModeChange={onModeChange}
onAgentChange={onAgentChange}
onAgentModelChange={onAgentModelChange}
onApiProtocolChange={onApiProtocolChange}
onApiModelChange={onApiModelChange}
onConfigPersist={onConfigPersist}
onRefreshAgents={onRefreshAgents}
renderDesignSystemCreation={renderDesignSystemCreation}
onFinish={finishOnboarding}
/>
</main>
</div>
);
}
return (
<div className="entry-shell entry-shell--no-header">
<div className="entry">
<EntryNavRail
view={view}
onViewChange={changeView}
onNewProject={() => openNewProject()}
/>
<main className="entry-main entry-main--scroll">
<div className="entry-main__topbar">
<div className="entry-main__topbar-chips">
<GithubStarBadge />
<a
className="entry-discord-badge"
href="https://discord.gg/mHAjSMV6gz"
aria-label="Join the Open Design Discord"
title="Join the Open Design Discord"
data-testid="entry-discord-badge"
>
<Icon name="discord" size={14} className="entry-discord-badge__icon" />
<span className="entry-discord-badge__label">Join Discord</span>
</a>
<InlineModelSwitcher
providerModelsCache={sharedProviderModelsCache}
config={config}
agents={agents}
daemonLive={daemonLive}
onModeChange={onModeChange}
onAgentChange={onAgentChange}
onAgentModelChange={onAgentModelChange}
onApiProtocolChange={onApiProtocolChange}
onApiModelChange={onApiModelChange}
onOpenSettings={onOpenSettings}
/>
<button
type="button"
className="use-everywhere-chip"
onClick={() => {
trackHomeToolbarClick(analytics.track, {
page_name: 'home',
area: 'toolbar',
element: 'use_everywhere',
});
openIntegrationTab('use-everywhere');
}}
title={t('entry.useEverywhereTitle')}
aria-label={t('entry.useEverywhereAria')}
data-testid="entry-use-everywhere-button"
>
<span className="use-everywhere-chip__icon" aria-hidden>
<Icon name="hammer" size={13} />
</span>
<span className="use-everywhere-chip__label">
{t('entry.useEverywhereTitle')}
</span>
</button>
</div>
<UpdaterPopup />
{avatarMenu}
</div>
<div
className={`entry-main__inner${
view === 'home' ? '' : ' entry-main__inner--wide'
}`}
>
{view === 'home' ? (
<HomeView
projects={projects}
projectsLoading={projectsLoading}
designSystems={designSystems}
defaultDesignSystemId={defaultDesignSystemId}
onSubmit={handlePluginLoopSubmit}
onOpenProject={onOpenProject}
onViewAllProjects={() => changeView('projects')}
onBrowseRegistry={() => changeView('plugins')}
onOpenNewProject={(tab) => {
// Stage B of plugin-driven-flow-plan: the rail's
// "From template" chip wires through here so the
// existing modal-based create flow still owns the
// template picker UI. Future tabs (e.g. live-artifact
// import) can reuse the same callback.
openNewProject(tab);
}}
promptHandoff={homePromptHandoff}
skills={skills}
skillsLoading={skillsLoading}
connectors={connectors}
promptTemplates={promptTemplates}
/>
) : null}
{view === 'projects' ? (
projectsLoading || skillsLoading || designSystemsLoading ? (
<CenteredLoader label={t('common.loading')} />
) : (
<div className="entry-section">
<header className="entry-section__head">
<h1 className="entry-section__title">{t('entry.navProjects')}</h1>
</header>
<DesignsTab
projects={projects}
skills={skills}
designSystems={designSystems}
onOpen={onOpenProject}
onOpenLiveArtifact={onOpenLiveArtifact}
onDelete={onDeleteProject}
onRename={onRenameProject}
onNewProject={() => openNewProject()}
/>
</div>
)
) : null}
{view === 'tasks' ? (
<TasksView
skills={skills}
designTemplates={designTemplates}
connectors={connectors}
connectorsLoading={connectorsLoading}
/>
) : null}
{view === 'plugins' ? (
<PluginsView
onCreatePlugin={startPluginAuthoring}
onUsePlugin={usePluginFromLibrary}
onCreatePluginShareProject={onCreatePluginShareProject}
/>
) : null}
{view === 'design-systems' ? (
designSystemsLoading ? (
<CenteredLoader label={t('common.loading')} />
) : (
<div className="entry-section">
<header className="entry-section__head">
<h1 className="entry-section__title">{t('entry.navDesignSystems')}</h1>
</header>
<DesignSystemsTab
systems={designSystems}
templates={templates}
selectedId={defaultDesignSystemId}
onSelect={onChangeDefaultDesignSystem}
onCreate={onCreateDesignSystem}
onOpenSystem={onOpenDesignSystem}
onSystemsRefresh={onDesignSystemsRefresh}
onPreview={(id) => setPreviewSystemId(id)}
/>
</div>
)
) : null}
{view === 'integrations' ? (
<IntegrationsView
config={config}
initialTab={integrationTab}
composioConfigLoading={composioConfigLoading}
onPersistComposioKey={onPersistComposioKey}
/>
) : null}
</div>
</main>
</div>
{previewSystem ? (
<DesignSystemPreviewModal
system={previewSystem}
onClose={() => setPreviewSystemId(null)}
/>
) : null}
<NewProjectModal
open={newProjectOpen}
initialTab={newProjectInitialTab}
skills={skills}
designSystems={designSystems}
defaultDesignSystemId={defaultDesignSystemId}
templates={templates}
{...(onDeleteTemplate ? { onDeleteTemplate } : {})}
promptTemplates={promptTemplates}
mediaProviders={config.mediaProviders}
connectors={connectors}
connectorsLoading={connectorsLoading}
loading={skillsLoading}
onCreate={handleCreate}
onImportClaudeDesign={onImportClaudeDesign}
{...(onImportFolder ? { onImportFolder } : {})}
{...(onImportFolderResponse ? { onImportFolderResponse } : {})}
onOpenConnectorsTab={() => {
setNewProjectOpen(false);
openIntegrationTab('connectors');
}}
onClose={() => setNewProjectOpen(false)}
/>
</div>
);
}
function OnboardingView({
config,
providerModelsCache: sharedProviderModelsCache,
onProviderModelsCacheChange,
agents,
daemonLive,
onModeChange,
onAgentChange,
onAgentModelChange,
onApiProtocolChange,
onApiModelChange,
onConfigPersist,
onRefreshAgents,
renderDesignSystemCreation,
onFinish,
}: {
config: AppConfig;
providerModelsCache?: Record<string, ProviderModelOption[]>;
onProviderModelsCacheChange?: Dispatch<SetStateAction<Record<string, ProviderModelOption[]>>>;
agents: AgentInfo[];
daemonLive: boolean;
onModeChange: (mode: ExecMode) => void;
onAgentChange: (id: string) => void;
onAgentModelChange: (
id: string,
choice: { model?: string; reasoning?: string },
) => void;
onApiProtocolChange: (protocol: ApiProtocol) => void;
onApiModelChange: (model: string) => void;
onConfigPersist: (cfg: AppConfig) => Promise<void> | void;
onRefreshAgents: () => Promise<AgentInfo[]> | AgentInfo[];
renderDesignSystemCreation?: (
onBack: () => void,
hooks?: {
onBeforeGenerate?: (snapshot: DesignSystemGenerateSnapshot) => void;
onGenerateSettled?: (
snapshot: DesignSystemGenerateSnapshot,
outcome:
| { result: 'success' }
| { result: 'failed'; errorCode: string },
) => void;
},
) => ReactNode;
onFinish: () => void;
}) {
const t = useT();
const analytics = useAnalytics();
const [step, setStep] = useState(0);
const [runtime, setRuntime] = useState<'amr' | 'local' | 'byok' | null>(null);
const [designSource, setDesignSource] = useState<'github' | 'upload' | 'prompt' | null>(null);
const [apiKeyVisible, setApiKeyVisible] = useState(false);
const [cliScanStatus, setCliScanStatus] = useState<'idle' | 'scanning' | 'done'>('idle');
const [amrStatus, setAmrStatus] = useState<VelaLoginStatus | null>(null);
const [amrLoginPending, setAmrLoginPending] = useState(false);
const [amrLoginError, setAmrLoginError] = useState(false);
const [visibleAgentIds, setVisibleAgentIds] = useState<string[]>([]);
const [providerTestState, setProviderTestState] = useState<
| { status: 'idle' }
| { status: 'running'; inputKey: string }
| { status: 'done'; inputKey: string; result: ConnectionTestResponse }
>({ status: 'idle' });
const [providerModelsState, setProviderModelsState] = useState<
| { status: 'idle' }
| { status: 'running'; inputKey: string }
| { status: 'done'; inputKey: string; result: ProviderModelsResponse }
>({ status: 'idle' });
const [localProviderModelsCache, setLocalProviderModelsCache] = useState<
Record<string, ProviderModelOption[]>
>({});
const providerModelsCache = sharedProviderModelsCache ?? localProviderModelsCache;
const setProviderModelsCache = onProviderModelsCacheChange ?? setLocalProviderModelsCache;
const [profile, setProfile] = useState({
role: '',
orgSize: '',
useCase: [] as string[],
source: '',
});
// Live mirror of `profile` so closures that fire faster than React
// commits (rapid dropdown picks, the Finish-setup click after the
// last onChange) read the latest selection instead of the value the
// closure captured at render-time. Multi-select use_case in
// particular needed this: two quick adds within one commit cycle
// both read `previous = new Set(profile.useCase = stale [])` and
// emitted on both — fine — but reading any cumulative summary off
// `profile` directly missed the second pick until the next commit.
const profileRef = useRef(profile);
useEffect(() => {
profileRef.current = profile;
}, [profile]);
const agentRevealTimersRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const cliScanTokenRef = useRef(0);
const amrLoginPollCancelledRef = useRef(false);
const amrAgentRefreshAttemptedRef = useRef(false);
const apiProtocol = config.apiProtocol ?? 'anthropic';
const providerTestInputKey = [
apiProtocol,
config.baseUrl.trim(),
config.model.trim(),
config.apiKey.trim(),
config.apiVersion?.trim() ?? '',
].join('\n');
const providerModelsInputKey = [
apiProtocol,
config.baseUrl.trim().replace(/\/+$/, ''),
config.apiKey.trim(),
config.apiVersion?.trim() ?? '',
].join('\n');
const canTestProvider =
Boolean(config.apiKey.trim()) &&
Boolean(config.baseUrl.trim()) &&
Boolean(config.model.trim());
const canFetchProviderModels =
apiProtocol !== 'azure' &&
apiProtocol !== 'ollama' &&
Boolean(config.apiKey.trim()) &&
Boolean(config.baseUrl.trim()) &&
isLikelyHttpUrl(config.baseUrl);
const visibleProviderTestState =
providerTestState.status !== 'idle' &&
providerTestState.inputKey === providerTestInputKey
? providerTestState
: { status: 'idle' as const };
const visibleProviderModelsState =
providerModelsState.status !== 'idle' &&
providerModelsState.inputKey === providerModelsInputKey
? providerModelsState
: { status: 'idle' as const };
const selectedProvider = KNOWN_PROVIDERS.find(
(provider) =>
provider.protocol === apiProtocol &&
provider.baseUrl === (config.apiProviderBaseUrl ?? config.baseUrl),
) ?? null;
const visibleAgents = agents.filter(
(agent) => agent.available && agent.id !== 'amr' && visibleAgentIds.includes(agent.id),
);
const amrAgent = agents.find((agent) => agent.id === 'amr' && agent.available) ?? null;
const showAmrCloudOption = amrAgent !== null || agents.length === 0;
const amrSignedIn = amrStatus?.loggedIn === true;
const amrSelectedAndSignedOut = runtime === 'amr' && !amrSignedIn;
const amrAgentChoice = config.agentModels?.amr ?? {};
const amrModels =
amrAgent?.models && amrAgent.models.length > 0
? amrAgent.models
: ONBOARDING_AMR_MODEL_OPTIONS;
const amrModelsSource =
amrAgent?.models && amrAgent.models.length > 0
? amrAgent.modelsSource ?? 'fallback'
: 'fallback';
const amrKnownModelIds = amrModels.map((model) => model.id);
const amrConfiguredModel =
typeof amrAgentChoice.model === 'string' && amrAgentChoice.model
? amrAgentChoice.model
: null;
const amrSelectedModel =
amrConfiguredModel && amrKnownModelIds.includes(amrConfiguredModel)
? amrConfiguredModel
: amrModels[0]?.id ?? '';
const selectedAgent = visibleAgents.find((agent) => agent.id === config.agentId) ?? null;
const selectedAgentChoice = selectedAgent ? (config.agentModels?.[selectedAgent.id] ?? {}) : {};
useEffect(() => {
return () => {
amrLoginPollCancelledRef.current = true;
agentRevealTimersRef.current.forEach((timer) => clearTimeout(timer));
agentRevealTimersRef.current = [];
};
}, []);
useEffect(() => {
if (!amrAgent || runtime !== null) return;
setRuntime('amr');
onModeChange('daemon');
onAgentChange('amr');
}, [amrAgent, onAgentChange, onModeChange, runtime]);
useEffect(() => {
if (amrAgent || amrAgentRefreshAttemptedRef.current) return;
amrAgentRefreshAttemptedRef.current = true;
void Promise.resolve(onRefreshAgents()).catch(() => undefined);
}, [amrAgent, onRefreshAgents]);
useEffect(() => {
if (!amrAgent) return;
let cancelled = false;
void fetchVelaLoginStatus().then((next) => {
if (!cancelled && next) setAmrStatus(next);
});
return () => {
cancelled = true;
};
}, [amrAgent]);
useEffect(() => {
if (runtime === 'amr') return;
amrLoginPollCancelledRef.current = true;
setAmrLoginPending(false);
}, [runtime]);
// Onboarding step exposure. Design-system intake used to live here
// as step 3, but it is temporarily removed from first-run
// onboarding and remains available from the app surfaces.
//
// We do NOT clear on unmount: route changes can remount the shell
// during first-run setup. Skip / Back / last-step Continue clear
// inline in their respective handlers below; abandoned sessions clear
// on sessionStorage tab close.
const onboardingSessionIdRef = useRef<string>('');
if (!onboardingSessionIdRef.current) {
onboardingSessionIdRef.current = getOrCreateOnboardingSessionId();
}
useEffect(() => {
const onboardingSessionId = onboardingSessionIdRef.current;
if (!onboardingSessionId) return;
let area: TrackingOnboardingArea;
let stepIndex: TrackingOnboardingStepIndex;
let stepName: TrackingOnboardingStepName;
if (step === 0) {
area = 'runtime';
stepIndex = '1';
stepName = 'connect';
} else if (step === 1) {
area = 'about_you';
stepIndex = '2';
stepName = 'about_you';
} else {
area = 'design_system';
stepIndex = '3';
stepName = 'design_system';
}
trackPageView(analytics.track, {
page_name: 'onboarding',
area,
step_index: stepIndex,
step_name: stepName,
onboarding_session_id: onboardingSessionId,
});
}, [analytics.track, step]);
// Onboarding analytics helpers. Wall-clock start so the lifecycle
// result event can carry `duration_ms`; `runtime` state is the user's
// current pick at click time so `runtime_type` rides along on every
// click. The `_lifecycleReportedRef` guards against double-firing the
// completion event when the user fires both Skip and unmount in the
// same tick (the unmount path also clears the session id; see the
// PR #2453 follow-up).
const onboardingStartedAtRef = useRef<number>(Date.now());
const lifecycleReportedRef = useRef(false);
function currentRuntimeType(): TrackingOnboardingRuntimeType {
if (runtime === 'amr') return 'amr_cloud';
if (runtime === 'local') return 'local_cli';
if (runtime === 'byok') return 'byok';
return 'none';
}
function stepInfo(stepIdx: number): {
area: TrackingOnboardingArea;
stepIndex: TrackingOnboardingStepIndex;
stepName: TrackingOnboardingStepName;
} {
if (stepIdx === 0) return { area: 'runtime', stepIndex: '1', stepName: 'connect' };
if (stepIdx === 1) return { area: 'about_you', stepIndex: '2', stepName: 'about_you' };
return { area: 'design_system', stepIndex: '3', stepName: 'design_system' };
}
// Pure mapping from `DesignSystemGenerateSnapshot` to the v2
// `TrackingOnboardingSourceType` enum. Single-source batches collapse
// to that source's literal; mixed batches go to `'mixed'`; the empty
// batch falls back to `'text'` when the user typed a brand
// description (prompt-only path, which the v2 contract reserves the
// `'text'` literal for) and `'none'` otherwise. The pre-fix version
// shipped `'none'` for prompt-only too, losing the prompt-only vs
// truly-empty distinction the dashboard needs.
function deriveOnboardingSourceType(
snapshot: DesignSystemGenerateSnapshot,
): import('@open-design/contracts/analytics').TrackingOnboardingSourceType {
if (snapshot.sourceCount === 0) {
return snapshot.hasBrandDescription ? 'text' : 'none';
}
if (snapshot.githubRepoCount === snapshot.sourceCount) return 'github_repo';
if (snapshot.localFolderCount === snapshot.sourceCount) return 'local_code';
if (snapshot.figFileCount === snapshot.sourceCount) return 'fig';
if (snapshot.assetFileCount === snapshot.sourceCount) return 'assets';
return 'mixed';
}
function emitOnboardingClick(
element: TrackingOnboardingClickElement,
action: TrackingOnboardingClickAction,
extra: Partial<Omit<
Parameters<typeof trackOnboardingClick>[1],
'page_name' | 'area' | 'element' | 'action' | 'step_index' | 'step_name' | 'onboarding_session_id'
>> = {},
): void {
const onboardingSessionId = onboardingSessionIdRef.current;
if (!onboardingSessionId) return;
const info = stepInfo(step);
trackOnboardingClick(analytics.track, {
page_name: 'onboarding',
area: info.area,
element,
action,
step_index: info.stepIndex,
step_name: info.stepName,
onboarding_session_id: onboardingSessionId,
...extra,
});
}
function emitOnboardingComplete(
result: TrackingOnboardingCompletionResult,
completionType: TrackingOnboardingCompletionType,
extra: {
errorCode?: string;
// Generate-path callers pass the embedded DS creation flow's
// snapshot so the wire row reflects the actual source-count
// and brand-description the user typed, not the (always-null)
// `designSource` card-pick state. E2E (2026-05-21) showed the
// user can click Generate without first clicking one of the
// three source-type cards — they go straight to typing a
// brand prompt — so reading `designSource` alone yielded
// `has_design_system_request: false` despite a real request.
sourceSnapshot?: DesignSystemGenerateSnapshot;
} = {},
): void {
if (lifecycleReportedRef.current) return;
const onboardingSessionId = onboardingSessionIdRef.current;
if (!onboardingSessionId) return;
lifecycleReportedRef.current = true;
const info = stepInfo(step);
const snapshot = extra.sourceSnapshot;
const hasRequest = snapshot
? snapshot.sourceCount > 0 || snapshot.hasBrandDescription
: Boolean(designSource);
const sourceCount = snapshot ? snapshot.sourceCount : 0;
// Read from `profileRef` for the same reason `emitAboutYouSubmit`
// does: a Finish-setup click may fire before React commits the
// latest dropdown pick, leaving `profile` (closure-captured at
// render time) one tick behind.
const liveProfile = profileRef.current;
const hasAboutYou = Boolean(
liveProfile.role
|| liveProfile.orgSize
|| liveProfile.useCase.length > 0
|| liveProfile.source,
);
trackOnboardingCompleteResult(analytics.track, {
page_name: 'onboarding',
area: 'onboarding',
result,
exit_step_name: info.stepName,
completion_type: completionType,
runtime_type: currentRuntimeType(),
has_about_you: hasAboutYou,
has_design_system_request: hasRequest,
source_count: sourceCount,
...(extra.errorCode ? { error_code: extra.errorCode } : {}),
duration_ms: Math.max(0, Date.now() - onboardingStartedAtRef.current),
onboarding_session_id: onboardingSessionId,
// Survey-snapshot mirror of `about_you_submit` so the funnel has
// a second carrier for the user's picks. Only attached when the
// user actually touched the About-you step.
...(hasAboutYou ? {
role: liveProfile.role || 'unknown',
organization_size: liveProfile.orgSize || 'unknown',
use_cases: liveProfile.useCase.length > 0
? liveProfile.useCase
: ['unknown'],
discovery_source: liveProfile.source || 'unknown',
} : {}),
});
}
const steps = [
t('settings.onboardingStepConnect'),
t('settings.onboardingStepProfile'),
];
const isLastStep = step === steps.length - 1;
const runtimeItems: Array<{
id: 'local' | 'byok';
icon: 'hammer' | 'sliders';
title: string;
body: string;
onSelect: () => void;
}> = [
{
id: 'local',
icon: 'hammer',
title: t('settings.onboardingLocalTitle'),
body: t('settings.onboardingLocalBody'),
onSelect: () => {
emitOnboardingClick('local_coding_agent', 'select_runtime', {
runtime_type: 'local_cli',
});
void scanCliAgents();
},
},
{
id: 'byok',
icon: 'sliders',
title: t('settings.onboardingByokTitle'),
body: t('settings.onboardingByokBody'),
onSelect: () => {
emitOnboardingClick('byok', 'select_runtime', { runtime_type: 'byok' });
setRuntime('byok');
onModeChange('api');
},
},
];
const designItems: Array<{
id: 'github' | 'upload' | 'prompt';
icon: 'github' | 'upload' | 'sparkles';
title: string;
body: string;
onSelect: () => void;
}> = [
{
id: 'github',
icon: 'github',
title: t('settings.onboardingGithubTitle'),
body: t('settings.onboardingGithubBody'),
onSelect: () => {
emitOnboardingClick('github_repo', 'add_source', {
source_type: 'github_repo',
});
setDesignSource('github');
},
},
{
id: 'upload',
icon: 'upload',
title: t('settings.onboardingUploadTitle'),
body: t('settings.onboardingUploadBody'),
onSelect: () => {
emitOnboardingClick('local_code', 'upload_source', {
source_type: 'local_code',
});
setDesignSource('upload');
},
},
{
id: 'prompt',
icon: 'sparkles',
title: t('settings.onboardingPromptTitle'),
body: t('settings.onboardingPromptBody'),
onSelect: () => {
emitOnboardingClick('fig_upload', 'upload_source', {
source_type: 'fig',
});
setDesignSource('prompt');
},
},
];
const roleOptions = [
{ value: 'pm', label: t('settings.onboardingRolePm') },
{ value: 'designer', label: t('settings.onboardingRoleDesigner') },
{ value: 'engineer', label: t('settings.onboardingRoleEngineer') },
{ value: 'marketing', label: t('settings.onboardingRoleMarketing') },
{ value: 'growth', label: t('settings.onboardingRoleGrowth') },
{ value: 'ops', label: t('settings.onboardingRoleOps') },
{ value: 'founder', label: t('settings.onboardingRoleFounder') },
{ value: 'student', label: t('settings.onboardingRoleStudent') },
{ value: 'other', label: t('settings.onboardingRoleOther') },
];
const orgSizeOptions = [
{ value: 'solo', label: t('settings.onboardingOrgSolo') },
{ value: 'team', label: t('settings.onboardingOrgTeam') },
{ value: 'startup', label: t('settings.onboardingOrgStartup') },
{ value: 'growth', label: t('settings.onboardingOrgGrowth') },
{ value: 'midmarket', label: t('settings.onboardingOrgMidMarket') },
{ value: 'enterprise', label: t('settings.onboardingOrgEnterprise') },
];
const useCaseOptions = [
{ value: 'product', label: t('settings.onboardingUseProduct') },
{ value: 'design-system', label: t('settings.onboardingUseDesignSystem') },
{ value: 'prototype', label: t('settings.onboardingUsePrototype') },
{ value: 'landing', label: t('settings.onboardingUseLanding') },
{ value: 'marketing', label: t('settings.onboardingUseMarketing') },
{ value: 'ads', label: t('settings.onboardingUseAds') },
{ value: 'dashboard', label: t('settings.onboardingUseDashboard') },
{ value: 'deck', label: t('settings.onboardingUseDeck') },
{ value: 'engineering', label: t('settings.onboardingUseEngineering') },
{ value: 'agency', label: t('settings.onboardingUseAgency') },
];
const sourceOptions = [
{ value: 'github', label: t('settings.onboardingSourceGithub') },
{ value: 'friend', label: t('settings.onboardingSourceFriend') },
{ value: 'social', label: t('settings.onboardingSourceSocial') },
{ value: 'product-hunt', label: t('settings.onboardingSourceProductHunt') },
{ value: 'community', label: t('settings.onboardingSourceCommunity') },
{ value: 'youtube', label: t('settings.onboardingSourceYoutube') },
{ value: 'blog', label: t('settings.onboardingSourceBlog') },
{ value: 'ai-tool', label: t('settings.onboardingSourceAiTool') },
{ value: 'search', label: t('settings.onboardingSourceSearch') },
{ value: 'event', label: t('settings.onboardingSourceEvent') },
];
const byokProviderOptions = [
{ value: '', label: t('settings.customProvider') },
...KNOWN_PROVIDERS.filter((provider) => provider.protocol === apiProtocol).map((provider) => ({
value: provider.baseUrl,
label: provider.label,
})),
];
const agentModelOptions =
selectedAgent?.models?.map((model) => ({
value: model.id,
label: model.label ?? model.id,
})) ?? [];
const fetchedProviderModels = providerModelsCache[providerModelsInputKey] ?? [];
const byokModelOptions = mergeOnboardingProviderModelOptions(
fetchedProviderModels,
SUGGESTED_MODELS_BY_PROTOCOL[apiProtocol],
config.model,
).map((model) => ({
value: model.id,
label: onboardingProviderModelLabel(model),
}));
function updateApiConfig(patch: Partial<ApiProtocolConfig>) {
const protocol = config.apiProtocol ?? 'anthropic';
const currentConfig: ApiProtocolConfig = {
apiKey: config.apiKey,
baseUrl: config.baseUrl,
model: config.model,
apiVersion: config.apiVersion ?? '',
apiProviderBaseUrl: config.apiProviderBaseUrl ?? null,
};
const nextProtocolConfig: ApiProtocolConfig = {
...currentConfig,
...patch,
};
const nextConfig: AppConfig = {
...config,
mode: 'api',
apiProtocol: protocol,
apiKey: nextProtocolConfig.apiKey,
baseUrl: nextProtocolConfig.baseUrl,
model: nextProtocolConfig.model,
apiVersion: protocol === 'azure' ? (nextProtocolConfig.apiVersion ?? '') : '',
apiProviderBaseUrl: nextProtocolConfig.apiProviderBaseUrl ?? null,
apiProtocolConfigs: {
...(config.apiProtocolConfigs ?? {}),
[protocol]: nextProtocolConfig,
},
};
void onConfigPersist(nextConfig);
}
function clearAgentRevealTimers() {
agentRevealTimersRef.current.forEach((timer) => clearTimeout(timer));
agentRevealTimersRef.current = [];
}
function handleSkipWithTracking(): void {
emitOnboardingClick('skip', 'skip');
emitOnboardingComplete('skipped', 'skipped');
clearOnboardingSessionId();
onFinish();
}
function handleBackWithTracking(): void {
if (step === 0) {
// Step 0 "Back" semantically maps to Skip — there's nowhere
// earlier to go. Match the Skip telemetry shape rather than
// emit a back-without-prior-step row.
handleSkipWithTracking();
return;
}
emitOnboardingClick('back', 'back');
setStep((current) => current - 1);
}
function handlePrimaryAction() {
if (step === 0 && amrSelectedAndSignedOut) {
void handleAmrSignInToContinue();
return;
}
if (isLastStep) {
// Emit the About-you survey snapshot FIRST, before the
// continue/complete pair. This is the bombproof carrier for the
// user's role / org size / use case / discovery source picks:
// per-dropdown clicks are racy on a fast Finish-setup (the user
// can pick all four dropdowns and click Finish inside one ~3s
// window, and PostHog's posthog-js client may not flush the
// individual rows before the route change unmounts the analytics
// provider). The snapshot click + the survey fields on
// `onboarding_complete_result` give the funnel two independent
// paths for the same data.
emitAboutYouSubmit();
emitOnboardingClick('continue', 'continue');
// Last-step Continue without a DS generation = "completed
// without design system". The Generate path inside the
// embedded DesignSystemCreationFlow takes a different route
// (navigation to project) and emits its own completion.
emitOnboardingComplete('completed', 'completed_without_design_system');
clearOnboardingSessionId();
onFinish();
return;
}
emitOnboardingClick('continue', 'continue');
setStep((current) => current + 1);
}
async function handleAmrSignInToContinue() {
if (amrLoginPending) return;
amrLoginPollCancelledRef.current = false;
setAmrLoginError(false);
setAmrLoginPending(true);
try {
const currentStatus = await fetchVelaLoginStatus();
if (currentStatus) setAmrStatus(currentStatus);
if (currentStatus?.loggedIn) {
setStep((current) => current + 1);
return;
}
const loginResult = await startVelaLogin();
if (!loginResult.ok && !loginResult.alreadyRunning) {
setAmrLoginError(true);
return;
}
if (await pollAmrLoginCompletion()) {
setStep((current) => current + 1);
}
} finally {
setAmrLoginPending(false);
}
}
async function pollAmrLoginCompletion(): Promise<boolean> {
const startedAt = Date.now();
while (!amrLoginPollCancelledRef.current) {
await new Promise((resolve) =>
window.setTimeout(resolve, AMR_LOGIN_POLL_INTERVAL_MS),
);
if (amrLoginPollCancelledRef.current) return false;
const nextStatus = await fetchVelaLoginStatus();
if (nextStatus) setAmrStatus(nextStatus);
const outcome = amrLoginPollOutcome(nextStatus, startedAt);
if (outcome === 'signed-in') return true;
if (outcome === 'stopped' || outcome === 'timed-out') {
if (outcome === 'timed-out') void cancelVelaLogin();
setAmrLoginError(true);
return false;
}
}
return false;
}
// Survey snapshot. Reads `profileRef.current` rather than `profile`
// because Finish-setup may fire within the same render commit as the
// user's last dropdown pick, before React has rebound the closure to
// the latest state. `'unknown'` covers an untouched field on the
// About-you step (the spec keeps the wire type open-string so a new
// role / use-case option doesn't force a contract bump).
function emitAboutYouSubmit(): void {
const snapshot = profileRef.current;
emitOnboardingClick('about_you_submit', 'continue', {
role: snapshot.role || 'unknown',
organization_size: snapshot.orgSize || 'unknown',
use_cases: snapshot.useCase.length > 0 ? snapshot.useCase : ['unknown'],
discovery_source: snapshot.source || 'unknown',
});
}
async function scanCliAgents() {
const scanToken = cliScanTokenRef.current + 1;
cliScanTokenRef.current = scanToken;
clearAgentRevealTimers();
setRuntime('local');
onModeChange('daemon');
setCliScanStatus('scanning');
setVisibleAgentIds([]);
const scanStartedAt = Date.now();
const onboardingSessionId = onboardingSessionIdRef.current;
const emitScanResult = (
args: {
result: 'success' | 'failed';
detected: number;
available: number;
selectedCliId?: TrackingCliProviderId;
errorCode?: string;
},
): void => {
if (!onboardingSessionId) return;
trackOnboardingRuntimeScanResult(analytics.track, {
page_name: 'onboarding',
area: 'runtime',
runtime_type: 'local_cli',
result: args.result,
detected_cli_count: args.detected,
available_cli_count: args.available,
...(args.selectedCliId ? { selected_cli_id: args.selectedCliId } : {}),
...(args.errorCode ? { error_code: args.errorCode } : {}),
duration_ms: Math.max(0, Date.now() - scanStartedAt),
onboarding_session_id: onboardingSessionId,
});
};
try {
const nextAgents = await onRefreshAgents();
if (cliScanTokenRef.current !== scanToken) return;
const availableAgents = nextAgents.filter((agent) => agent.available && agent.id !== 'amr');
// If the user previously had AMR selected (e.g. it was auto-picked once
// we detected vela) and they have now chosen the Local CLI path, the
// persisted agentId is still 'amr' and would survive Continue without
// an explicit click on a local agent card. Switch the selection to the
// first available local agent as soon as we have one, so the runtime
// and the persisted agent always agree.
if (config.agentId === 'amr' && availableAgents[0]) {
onAgentChange(availableAgents[0].id);
}
// Scan-result semantics: zero available CLIs is a `failed` outcome
// because the user's runtime path is blocked, even though the
// detect call itself returned successfully. `detected_cli_count`
// separately reports the raw catalog so the dashboard can split
// "user has no CLI installed" from "detect crashed".
if (availableAgents.length === 0) {
setCliScanStatus('done');
emitScanResult({
result: 'failed',
detected: nextAgents.length,
available: 0,
errorCode: 'NO_AVAILABLE_CLI',
});
return;
}
emitScanResult({
result: 'success',
detected: nextAgents.length,
available: availableAgents.length,
...(availableAgents[0]
? { selectedCliId: agentIdToTracking(availableAgents[0].id) }
: {}),
});
availableAgents.forEach((agent, index) => {
const timer = setTimeout(() => {
if (cliScanTokenRef.current !== scanToken) return;
setVisibleAgentIds((current) =>
current.includes(agent.id) ? current : [...current, agent.id],
);
if (index === availableAgents.length - 1) {
setCliScanStatus('done');
}
}, 110 * (index + 1));
agentRevealTimersRef.current.push(timer);
});
} catch (err) {
if (cliScanTokenRef.current === scanToken) {
setCliScanStatus('done');
emitScanResult({
result: 'failed',
detected: 0,
available: 0,
errorCode: err instanceof Error ? err.message : 'AGENT_REFRESH_THREW',
});
}
}
}
async function testProviderInline() {
if (!canTestProvider || providerTestState.status === 'running') return;
const inputKey = providerTestInputKey;
setProviderTestState({ status: 'running', inputKey });
try {
const result = await testApiProvider({
protocol: apiProtocol,
baseUrl: config.baseUrl,
apiKey: config.apiKey,
model: config.model,
apiVersion:
apiProtocol === 'azure'
? config.apiVersion?.trim() || undefined
: undefined,
});
setProviderTestState({ status: 'done', inputKey, result });
} catch (error) {
setProviderTestState({
status: 'done',
inputKey,
result: {
ok: false,
kind: 'unknown',
latencyMs: 0,
model: config.model,
detail: error instanceof Error ? error.message : 'Test request failed',
},
});
}
}
async function fetchProviderModelsInline() {
if (!canFetchProviderModels || providerModelsState.status === 'running') return;
const inputKey = providerModelsInputKey;
const cachedModels = providerModelsCache[inputKey];
if (cachedModels) {
setProviderModelsState({
status: 'done',
inputKey,
result: {
ok: true,
kind: 'success',
latencyMs: 0,
models: cachedModels,
},
});
return;
}
setProviderModelsState({ status: 'running', inputKey });
try {
const result = await fetchProviderModels({
protocol: apiProtocol,
baseUrl: config.baseUrl,
apiKey: config.apiKey,
});
if (result.ok && result.models?.length) {
setProviderModelsCache((current) => ({
...current,
[inputKey]: result.models ?? [],
}));
}
setProviderModelsState({ status: 'done', inputKey, result });
} catch (error) {
setProviderModelsState({
status: 'done',
inputKey,
result: {
ok: false,
kind: 'unknown',
latencyMs: 0,
detail: error instanceof Error ? error.message : 'Model list request failed',
},
});
}
}
const primaryActionLabel = step === 0 && amrLoginPending
? t('settings.amrSigningIn')
: step === 0 && amrSelectedAndSignedOut
? t('settings.amrSignInToContinue')
: step === 1
? t('settings.onboardingContinue')
: isLastStep
? t('settings.onboardingFinish')
: t('settings.onboardingContinue');
return (
<section className="onboarding-view" aria-labelledby="onboarding-title">
<header className="onboarding-view__hero">
{t('settings.welcomeKicker') ? (
<span className="onboarding-view__kicker">{t('settings.welcomeKicker')}</span>
) : null}
<h1 id="onboarding-title">{t('settings.welcomeTitle')}</h1>
{t('settings.welcomeSubtitle') ? <p>{t('settings.welcomeSubtitle')}</p> : null}
</header>
<ol className="onboarding-view__steps" aria-label={t('settings.welcomeTitle')}>
{steps.map((label, index) => (
<li key={label} className={index === step ? 'is-active' : index < step ? 'is-done' : ''}>
<span>{index + 1}</span>
<button type="button" onClick={() => setStep(index)}>
{label}
</button>
</li>
))}
</ol>
<div className="onboarding-view__body">
<div className="onboarding-view__content">
{step === 0 ? (
<div className="onboarding-view__panel">
<OnboardingPanelHeader
title={t('settings.onboardingConnectTitle')}
body={t('settings.onboardingConnectBody')}
/>
<div className="onboarding-view__runtime-stack">
{showAmrCloudOption ? (
<div className="onboarding-view__amr-cloud-card">
<OnboardingChoiceCard
icon="orbit"
agentIconId="amr"
title={t('settings.amrCloud')}
body={t('settings.onboardingExecutionBody')}
benefits={[
t('settings.onboardingAmrCloudBenefitOfficial'),
t('settings.onboardingAmrCloudBenefitReady'),
t('settings.onboardingAmrCloudBenefitModels'),
t('settings.onboardingAmrCloudBenefitPricing'),
]}
upcomingLabel={t('settings.onboardingAmrCloudUpcomingLabel')}
upcomingBenefits={[
t('settings.onboardingAmrCloudUpcomingImageVideo'),
t('settings.onboardingAmrCloudUpcomingSkills'),
t('settings.onboardingAmrCloudUpcomingRouting'),
]}
benefitPlacement="aside"
metaLabel="AMR v0.1.0"
modelSlot={
amrModels.length > 0 ? (
<OnboardingAmrModelSelect
models={amrModels}
modelsSource={amrModelsSource}
selectedModel={amrSelectedModel}
onSelectModel={(model) => onAgentModelChange('amr', { model })}
/>
) : null
}
variant="amr"
featured
selected={runtime === 'amr'}
onClick={() => {
setRuntime('amr');
onModeChange('daemon');
onAgentChange('amr');
}}
/>
</div>
) : null}
<div className="onboarding-view__alternatives">
{runtimeItems.map((item) => (
<OnboardingChoiceCard
key={item.id}
icon={item.icon}
title={item.title}
body={item.body}
selected={runtime === item.id}
onClick={item.onSelect}
/>
))}
</div>
{runtime === 'local' ? (
<OnboardingCliSetupPanel
agents={visibleAgents}
daemonLive={daemonLive}
selectedAgentId={config.agentId}
selectedAgent={selectedAgent}
selectedModel={selectedAgentChoice.model ?? selectedAgent?.models?.[0]?.id ?? ''}
modelOptions={agentModelOptions}
scanStatus={cliScanStatus}
onRefresh={() => void scanCliAgents()}
onSelectAgent={(agentId) => {
onModeChange('daemon');
onAgentChange(agentId);
}}
onSelectModel={(model) => {
if (!selectedAgent) return;
onAgentModelChange(selectedAgent.id, { model });
}}
/>
) : null}
{runtime === 'byok' ? (
<OnboardingByokSetupPanel
apiProtocol={apiProtocol}
apiKey={config.apiKey}
baseUrl={config.baseUrl}
model={config.model}
selectedProvider={selectedProvider}
providerOptions={byokProviderOptions}
apiKeyVisible={apiKeyVisible}
onToggleApiKey={() => setApiKeyVisible((current) => !current)}
onProtocolChange={(protocol) => {
onApiProtocolChange(protocol);
}}
onProviderChange={(baseUrl) => {
const provider = KNOWN_PROVIDERS.find(
(item) => item.protocol === apiProtocol && item.baseUrl === baseUrl,
);
updateApiConfig({
baseUrl: provider?.baseUrl ?? '',
model: provider?.model ?? '',
apiProviderBaseUrl: provider?.baseUrl ?? null,
});
}}
onApiKeyChange={(apiKey) => updateApiConfig({ apiKey })}
onModelChange={(model) => {
onApiModelChange(model);
updateApiConfig({ model });
}}
onBaseUrlChange={(baseUrl) =>
updateApiConfig({ baseUrl, apiProviderBaseUrl: null })
}
modelOptions={byokModelOptions}
testState={visibleProviderTestState}
canTest={canTestProvider}
onTest={() => void testProviderInline()}
modelsState={visibleProviderModelsState}
canFetchModels={canFetchProviderModels}
onFetchModels={() => void fetchProviderModelsInline()}
/>
) : null}
</div>
</div>
) : null}
{step === 1 ? (
<div className="onboarding-view__panel">
<OnboardingPanelHeader
title={t('settings.onboardingProfileTitle')}
body={t('settings.onboardingProfileBody')}
/>
<div className="onboarding-view__form-grid">
<OnboardingDropdown
label={t('settings.onboardingRoleLabel')}
placeholder={t('settings.onboardingSelectPlaceholder')}
value={profile.role}
options={roleOptions}
onChange={(value) => {
if (typeof value === 'string' && value) {
emitOnboardingClick('role', 'select_option', {
role: value,
});
}
setProfile((current) => ({ ...current, role: value }));
}}
/>
<OnboardingDropdown
label={t('settings.onboardingOrgSizeLabel')}
placeholder={t('settings.onboardingSelectPlaceholder')}
value={profile.orgSize}
options={orgSizeOptions}
onChange={(value) => {
if (typeof value === 'string' && value) {
emitOnboardingClick('organization_size', 'select_option', {
organization_size: value,
});
}
setProfile((current) => ({ ...current, orgSize: value }));
}}
/>
<OnboardingDropdown
label={t('settings.onboardingUseCaseLabel')}
placeholder={t('settings.onboardingSelectMultiplePlaceholder')}
value={profile.useCase}
options={useCaseOptions}
multiple
onChange={(value) => {
if (!Array.isArray(value)) return;
// Multi-select: emit one click per newly added
// value (delta), not per render of the whole
// selection. The dashboard then sees one row per
// use_case chosen. Compare against `profileRef`
// not `profile` — rapid picks can fire onChange
// before React commits the previous pick, so a
// closure-captured `profile.useCase` is one tick
// behind and re-emits the prior pick on every
// subsequent change.
const previousSet = new Set(profileRef.current.useCase);
for (const v of value) {
if (!previousSet.has(v)) {
emitOnboardingClick('use_case', 'select_option', { use_case: v });
}
}
setProfile((current) => ({ ...current, useCase: value }));
}}
/>
<OnboardingDropdown
label={t('settings.onboardingSourceLabel')}
placeholder={t('settings.onboardingSelectPlaceholder')}
value={profile.source}
options={sourceOptions}
onChange={(value) => {
if (typeof value === 'string' && value) {
emitOnboardingClick('hear_about_us', 'select_option', {
discovery_source: value,
});
}
setProfile((current) => ({ ...current, source: value }));
}}
/>
</div>
</div>
) : null}
{step === 2 && renderDesignSystemCreation ? (
<div className="onboarding-view__design-system-create">
<div className="onboarding-view__ds-intro">
<OnboardingPanelHeader
title={t('settings.onboardingDesignTitle')}
body={t('settings.onboardingDesignBody')}
/>
<div className="onboarding-view__ds-points">
<div>
<strong>{t('settings.onboardingDesignIntroGenerateTitle')}</strong>
<span>{t('settings.onboardingDesignIntroGenerateBody')}</span>
</div>
<div>
<strong>{t('settings.onboardingDesignIntroReuseTitle')}</strong>
<span>{t('settings.onboardingDesignIntroReuseBody')}</span>
</div>
</div>
<button type="button" className="onboarding-view__ds-skip" onClick={handleSkipWithTracking}>
{t('settings.onboardingSkip')}
</button>
</div>
{renderDesignSystemCreation(() => setStep(1), {
onBeforeGenerate: (snapshot) => {
// INTENT signal — fires before async DS-draft create
// / workspace-open work runs. Use it ONLY for the
// `generate` click row so the dashboard captures
// user intent even when generation later errors.
// The lifecycle `onboarding_complete_result` row
// moved to `onGenerateSettled` below so a draft
// create failure no longer ships as
// `completion_type=completed_with_design_system`.
emitOnboardingClick('generate', 'generate', {
source_type: deriveOnboardingSourceType(snapshot),
source_count: snapshot.sourceCount,
has_brand_description: snapshot.hasBrandDescription,
});
},
onGenerateSettled: (snapshot, outcome) => {
// OUTCOME signal — fires from `DesignSystemCreationFlow`
// *after* the create/workspace branch settles.
// Success → emit lifecycle complete row with
// `completion_type=completed_with_design_system`.
// Generation hand-off navigates away from this
// tab; the post-Generate `chat_panel` page_view
// in ProjectView fires the 4th-step
// `area=generation_progress` row and clears the
// session id. Don't clear here.
// Failure → emit lifecycle complete with
// `result=failed`, the daemon's failure code, and
// `completed_without_design_system` so we don't
// overstate completed-with-DS funnel. Then re-arm
// the lifecycle guard (don't clear the session
// id) so the user's retry attempt — which
// DesignSystemCreationFlow leaves them in by
// bouncing back to its setup step — emits a
// second complete row under the SAME
// onboarding_session_id, and any eventual
// success can still navigate to ProjectView with
// the id intact for step 4. Tracked by mrcfps
// review on PR #2590 (2026-05-21 14:45).
if (outcome.result === 'success') {
emitOnboardingComplete(
'completed',
'completed_with_design_system',
{ sourceSnapshot: snapshot },
);
return;
}
emitOnboardingComplete(
'failed',
'completed_without_design_system',
{ sourceSnapshot: snapshot, errorCode: outcome.errorCode },
);
lifecycleReportedRef.current = false;
},
})}
</div>
) : null}
{step === 2 && !renderDesignSystemCreation ? (
<div className="onboarding-view__panel">
<OnboardingPanelHeader
title={t('settings.onboardingDesignTitle')}
body={t('settings.onboardingDesignBody')}
/>
<div className="onboarding-view__grid">
{designItems.map((item) => (
<OnboardingChoiceCard
key={item.id}
icon={item.icon}
title={item.title}
body={item.body}
selected={designSource === item.id}
onClick={item.onSelect}
/>
))}
</div>
</div>
) : null}
{step === 2 && renderDesignSystemCreation ? null : (
<div className="onboarding-view__actions">
{step === 0 && amrLoginError ? (
<span className="onboarding-view__action-status is-error" role="alert">
{t('settings.amrLoginErrorCompact')}
</span>
) : null}
<button
type="button"
className="onboarding-view__secondary"
onClick={handleBackWithTracking}
>
{step === 0 ? t('settings.onboardingSkip') : t('settings.onboardingBack')}
</button>
<button
type="button"
className="onboarding-view__primary"
onClick={handlePrimaryAction}
disabled={amrLoginPending}
>
<span>{primaryActionLabel}</span>
</button>
</div>
)}
</div>
</div>
</section>
);
}
function OnboardingCliSetupPanel({
agents,
daemonLive,
selectedAgentId,
selectedAgent,
selectedModel,
modelOptions,
scanStatus,
onRefresh,
onSelectAgent,
onSelectModel,
}: {
agents: AgentInfo[];
daemonLive: boolean;
selectedAgentId: string | null;
selectedAgent: AgentInfo | null;
selectedModel: string;
modelOptions: Array<{ value: string; label: string }>;
scanStatus: 'idle' | 'scanning' | 'done';
onRefresh: () => void;
onSelectAgent: (agentId: string) => void;
onSelectModel: (model: string) => void;
}) {
const t = useT();
const scanning = scanStatus === 'scanning';
const showEmpty = scanStatus === 'done' && agents.length === 0;
return (
<div className="onboarding-view__setup-panel">
<div className="onboarding-view__setup-head">
<div>
<strong>{t('settings.localCli')}</strong>
<p>{daemonLive ? t('settings.codeAgentHint') : t('settings.modeDaemonOffline')}</p>
</div>
<button
type="button"
className={`onboarding-view__mini-button${scanning ? ' is-loading' : ''}`}
onClick={onRefresh}
disabled={scanning}
>
{scanning ? t('settings.rescanRunning') : t('settings.rescan')}
</button>
</div>
{scanning ? (
<div className="onboarding-view__scan-copy" role="status">
<p className="onboarding-view__scan-status">
<Icon name="spinner" size={13} className="icon-spin" />
<span>{t('settings.rescanRunning')}</span>
</p>
<p className="onboarding-view__scan-hint">
{t('settings.onboardingCliScanHint')}
</p>
</div>
) : null}
{agents.length > 0 ? (
<div className="onboarding-view__agent-strip">
{agents.map((agent, index) => (
<button
key={agent.id}
type="button"
className={`onboarding-view__agent-chip${
selectedAgentId === agent.id ? ' is-selected' : ''
}`}
style={{ animationDelay: `${index * 45}ms` }}
onClick={() => onSelectAgent(agent.id)}
aria-pressed={selectedAgentId === agent.id}
>
<AgentIcon id={agent.id} size={22} />
<span>
<strong>{agent.name}</strong>
<small>{agent.version ?? t('common.installed')}</small>
</span>
</button>
))}
</div>
) : null}
{showEmpty ? (
<div className="onboarding-view__empty-slice">
{t('settings.noAgentsDetected')}
</div>
) : null}
{selectedAgent && modelOptions.length > 0 ? (
<OnboardingDropdown
label={`${t('settings.modelPicker')} · ${selectedAgent.name}`}
placeholder={t('settings.modelSourceFallback')}
value={selectedModel}
options={modelOptions}
onChange={onSelectModel}
/>
) : null}
</div>
);
}
function OnboardingAmrModelSelect({
models,
modelsSource,
selectedModel,
onSelectModel,
}: {
models: NonNullable<AgentInfo['models']>;
modelsSource: AgentInfo['modelsSource'];
selectedModel: string;
onSelectModel: (model: string) => void;
}) {
const t = useT();
const modelSource = modelsSource ?? 'fallback';
const displayModels = models.map((model) => ({
...model,
label: formatOnboardingAmrModelLabel(model),
}));
const modelSourceLabel = t('settings.onboardingAmrModelSourceLabel');
return (
<label
className="onboarding-view__model-picker"
onClick={(event) => event.stopPropagation()}
>
<span className="onboarding-view__model-label">
{t('settings.modelPicker')}
<span className={`onboarding-view__model-source ${modelSource}`}>
{modelSourceLabel}
</span>
</span>
<span className="onboarding-view__model-select-wrap">
<select
value={selectedModel}
onChange={(event) => onSelectModel(event.target.value)}
>
{renderModelOptions(displayModels)}
</select>
<Icon
name="chevron-down"
size={12}
className="onboarding-view__model-select-chevron"
/>
</span>
</label>
);
}
function formatOnboardingAmrModelLabel(
model: NonNullable<AgentInfo['models']>[number],
): string {
const label = model.label?.trim();
if (label && label !== model.id && !/^[a-z0-9._-]+$/.test(label)) {
return label;
}
return model.id
.split('-')
.filter(Boolean)
.map(formatModelToken)
.join(' ');
}
function formatModelToken(token: string): string {
const lower = token.toLowerCase();
const known: Record<string, string> = {
claude: 'Claude',
opus: 'Opus',
sonnet: 'Sonnet',
haiku: 'Haiku',
deepseek: 'DeepSeek',
gemini: 'Gemini',
glm: 'GLM',
gpt: 'GPT',
oss: 'OSS',
kimi: 'Kimi',
minimax: 'MiniMax',
mimo: 'MiMo',
qwen3: 'Qwen3',
seed: 'Seed',
};
if (known[lower]) return known[lower];
if (/^v\d/i.test(token)) return token.toUpperCase();
if (/^\d+b$/i.test(token) || /^a\d+b$/i.test(token)) return token.toUpperCase();
if (/^\d+(\.\d+)*$/.test(token)) return token;
return token.charAt(0).toUpperCase() + token.slice(1);
}
function OnboardingByokSetupPanel({
apiProtocol,
apiKey,
baseUrl,
model,
selectedProvider,
providerOptions,
apiKeyVisible,
onToggleApiKey,
onProtocolChange,
onProviderChange,
onApiKeyChange,
onModelChange,
onBaseUrlChange,
modelOptions,
testState,
canTest,
onTest,
modelsState,
canFetchModels,
onFetchModels,
}: {
apiProtocol: ApiProtocol;
apiKey: string;
baseUrl: string;
model: string;
selectedProvider: KnownProvider | null;
providerOptions: Array<{ value: string; label: string }>;
modelOptions: Array<{ value: string; label: string }>;
apiKeyVisible: boolean;
onToggleApiKey: () => void;
onProtocolChange: (protocol: ApiProtocol) => void;
onProviderChange: (baseUrl: string) => void;
onApiKeyChange: (apiKey: string) => void;
onModelChange: (model: string) => void;
onBaseUrlChange: (baseUrl: string) => void;
testState:
| { status: 'idle' }
| { status: 'running'; inputKey: string }
| { status: 'done'; inputKey: string; result: ConnectionTestResponse };
canTest: boolean;
onTest: () => void;
modelsState:
| { status: 'idle' }
| { status: 'running'; inputKey: string }
| { status: 'done'; inputKey: string; result: ProviderModelsResponse };
canFetchModels: boolean;
onFetchModels: () => void;
}) {
const t = useT();
const running = testState.status === 'running';
const fetchingModels = modelsState.status === 'running';
return (
<div className="onboarding-view__setup-panel">
<div className="onboarding-view__setup-head">
<div>
<strong>{t('settings.modeApiMeta')}</strong>
<p>{t('settings.modeApi')}</p>
</div>
<div className="onboarding-view__setup-head-actions">
<button
type="button"
className={`onboarding-view__mini-button${fetchingModels ? ' is-loading' : ''}`}
onClick={onFetchModels}
disabled={fetchingModels || !canFetchModels}
title={t('settings.fetchModelsTitle')}
>
{fetchingModels ? t('settings.fetchModelsRunning') : t('settings.fetchModels')}
</button>
<button
type="button"
className={`onboarding-view__mini-button${running ? ' is-loading' : ''}`}
onClick={onTest}
disabled={running || !canTest}
title={t('settings.testTitle')}
>
{running ? t('settings.testRunning') : t('settings.test')}
</button>
</div>
</div>
<div
className="onboarding-view__protocol-strip"
role="tablist"
aria-label={t('settings.protocolAria')}
>
{API_PROTOCOL_TABS.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={apiProtocol === tab.id}
className={apiProtocol === tab.id ? 'is-selected' : ''}
onClick={() => onProtocolChange(tab.id)}
>
{tab.title}
</button>
))}
</div>
<OnboardingDropdown
label={t('settings.quickFillProvider')}
placeholder={t('settings.customProvider')}
value={selectedProvider?.baseUrl ?? ''}
options={providerOptions}
onChange={onProviderChange}
/>
<label className="onboarding-view__inline-field">
<span>{t('settings.apiKey')}</span>
<span className="onboarding-view__field-row">
<input
type={apiKeyVisible ? 'text' : 'password'}
placeholder={API_KEY_PLACEHOLDERS[apiProtocol]}
value={apiKey}
onChange={(event) => onApiKeyChange(event.target.value)}
/>
<button type="button" onClick={onToggleApiKey}>
{apiKeyVisible ? t('settings.hide') : t('settings.show')}
</button>
</span>
</label>
<div className="onboarding-view__compact-fields">
<label className="onboarding-view__inline-field">
<span>{t('settings.baseUrl')}</span>
<input
type="url"
inputMode="url"
value={baseUrl}
placeholder={selectedProvider?.baseUrl ?? 'https://api.anthropic.com'}
onChange={(event) => onBaseUrlChange(event.target.value)}
/>
</label>
{modelOptions.length > 0 ? (
<OnboardingDropdown
label={t('settings.model')}
placeholder={selectedProvider?.model ?? 'claude-sonnet-4-5'}
value={model}
options={modelOptions}
onChange={onModelChange}
placement="top"
/>
) : (
<label className="onboarding-view__inline-field">
<span>{t('settings.model')}</span>
<input
type="text"
value={model}
placeholder={selectedProvider?.model ?? 'claude-sonnet-4-5'}
onChange={(event) => onModelChange(event.target.value.trim())}
/>
</label>
)}
</div>
{modelsState.status === 'running' ? (
<p className="onboarding-view__test-status is-running" role="status">
{t('settings.fetchModelsRunning')}
</p>
) : modelsState.status === 'done' ? (
<p
className={`onboarding-view__test-status is-${onboardingProviderModelsVariant(
modelsState.result,
)}`}
role={modelsState.result.ok ? 'status' : 'alert'}
>
{renderOnboardingProviderModelsMessage(t, modelsState.result)}
</p>
) : null}
{testState.status === 'running' ? (
<p className="onboarding-view__test-status is-running" role="status">
{t('settings.testRunning')}
</p>
) : testState.status === 'done' ? (
<p
className={`onboarding-view__test-status is-${onboardingTestVariant(
testState.result,
)}`}
role={testState.result.ok ? 'status' : 'alert'}
>
{renderOnboardingProviderTestMessage(t, testState.result, model)}
</p>
) : null}
</div>
);
}
function onboardingTestVariant(
result: ConnectionTestResponse,
): 'success' | 'warn' | 'error' {
if (result.ok) return 'success';
if (result.kind === 'rate_limited') return 'warn';
return 'error';
}
function onboardingProviderModelsVariant(
result: ProviderModelsResponse,
): 'success' | 'warn' | 'error' {
if (result.ok) return 'success';
if (result.kind === 'rate_limited' || result.kind === 'no_models') return 'warn';
return 'error';
}
function isLikelyHttpUrl(value: string): boolean {
try {
const parsed = new URL(value.trim());
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
function mergeOnboardingProviderModelOptions(
fetchedModels: readonly ProviderModelOption[],
suggestedModelIds: readonly string[],
currentModel: string,
): ProviderModelOption[] {
const seen = new Set<string>();
const out: ProviderModelOption[] = [];
const add = (model: ProviderModelOption) => {
const id = model.id.trim();
if (!id || seen.has(id)) return;
seen.add(id);
out.push({ id, label: model.label.trim() || id });
};
for (const model of fetchedModels) add(model);
for (const id of suggestedModelIds) add({ id, label: id });
if (currentModel.trim()) add({ id: currentModel.trim(), label: currentModel.trim() });
return out;
}
function onboardingProviderModelLabel(model: ProviderModelOption): string {
return model.label && model.label !== model.id
? `${model.label} (${model.id})`
: model.id;
}
function renderOnboardingProviderTestMessage(
t: ReturnType<typeof useT>,
result: ConnectionTestResponse,
fallbackModel: string,
): string {
const ms = Math.max(0, Math.round(result.latencyMs));
const sample = result.sample ?? '';
const testedModel = result.model ?? fallbackModel;
if (result.ok) {
const baseMessage = t('settings.testSuccessApi', { ms, sample });
return result.detail ? `${baseMessage} ${result.detail}` : baseMessage;
}
switch (result.kind) {
case 'auth_failed':
return t('settings.testAuthFailed');
case 'forbidden':
return t('settings.testForbidden');
case 'not_found_model':
return t('settings.testNotFoundModel', { model: testedModel });
case 'invalid_model_id':
return t('settings.testInvalidModelId', { model: testedModel });
case 'invalid_base_url':
return t('settings.testInvalidBaseUrl');
case 'rate_limited':
return t('settings.testRateLimited');
case 'upstream_unavailable':
return t('settings.testUpstream', { status: result.status ?? 0 });
case 'timeout':
return t('settings.testTimeout', { ms });
default:
return t('settings.testUnknown', { detail: result.detail ?? '' });
}
}
function renderOnboardingProviderModelsMessage(
t: ReturnType<typeof useT>,
result: ProviderModelsResponse,
): string {
if (result.ok) {
return t('settings.fetchModelsSuccess', {
count: result.models?.length ?? 0,
});
}
switch (result.kind) {
case 'auth_failed':
return t('settings.testAuthFailed');
case 'forbidden':
return t('settings.testForbidden');
case 'invalid_base_url':
return t('settings.testInvalidBaseUrl');
case 'rate_limited':
return t('settings.testRateLimited');
case 'upstream_unavailable':
return t('settings.testUpstream', { status: result.status ?? 0 });
case 'timeout':
return t('settings.testTimeout', {
ms: Math.max(0, Math.round(result.latencyMs)),
});
case 'no_models':
return t('settings.fetchModelsEmpty');
case 'unsupported_protocol':
return t('settings.fetchModelsUnsupported');
default:
return t('settings.fetchModelsFailed', { detail: result.detail ?? '' });
}
}
function OnboardingPanelHeader({ title, body }: { title: string; body: string }) {
return (
<div className="onboarding-view__panel-head">
<h2>{title}</h2>
<p>{body}</p>
</div>
);
}
type OnboardingDropdownBaseProps = {
label: string;
placeholder: string;
options: Array<{ value: string; label: string }>;
placement?: 'bottom' | 'top';
};
type OnboardingDropdownProps =
| (OnboardingDropdownBaseProps & {
value: string;
onChange: (value: string) => void;
multiple?: false;
})
| (OnboardingDropdownBaseProps & {
value: string[];
onChange: (value: string[]) => void;
multiple: true;
});
function OnboardingDropdown(props: OnboardingDropdownProps) {
const {
label,
placeholder,
value,
options,
placement = 'bottom',
multiple = false,
} = props;
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const selectedValues = Array.isArray(value) ? value : value ? [value] : [];
const selectedOptions = options.filter((option) => selectedValues.includes(option.value));
const selectedOption = selectedOptions[0];
const hasValue = selectedOptions.length > 0;
const selectedLabel = multiple
? selectedOptions.map((option) => option.label).join(', ')
: selectedOption?.label;
useEffect(() => {
if (!open) return;
function handlePointerDown(event: PointerEvent) {
if (!rootRef.current?.contains(event.target as Node)) {
setOpen(false);
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
setOpen(false);
}
}
document.addEventListener('pointerdown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [open]);
return (
<div className="onboarding-view__select-field" data-placement={placement} ref={rootRef}>
<span className="onboarding-view__select-label">{label}</span>
<button
type="button"
className={`onboarding-view__select-trigger${open ? ' is-open' : ''}${
hasValue ? ' has-value' : ''
}`}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
<span>{selectedLabel || placeholder}</span>
<Icon name="chevron-down" size={16} />
</button>
{open ? (
<div
className="onboarding-view__select-menu"
role="listbox"
aria-label={label}
aria-multiselectable={multiple || undefined}
>
{options.map((option) => {
const selected = selectedValues.includes(option.value);
return (
<button
key={option.value}
type="button"
className={`onboarding-view__select-option${selected ? ' is-selected' : ''}`}
role="option"
aria-selected={selected}
onClick={() => {
if (props.multiple) {
props.onChange(
selected
? selectedValues.filter((selectedValue) => selectedValue !== option.value)
: [...selectedValues, option.value],
);
return;
}
props.onChange(option.value);
setOpen(false);
}}
>
<span>{option.label}</span>
{selected ? <Icon name="check" size={15} /> : null}
</button>
);
})}
</div>
) : null}
</div>
);
}
function OnboardingChoiceCard({
icon,
agentIconId,
title,
body,
benefits,
upcomingLabel,
upcomingBenefits,
benefitPlacement = 'copy',
metaLabel,
modelSlot,
actionLabel,
selected,
badge,
statusSlot,
featured,
variant,
onClick,
}: {
icon: 'orbit' | 'hammer' | 'sliders' | 'github' | 'upload' | 'sparkles';
agentIconId?: string;
title: string;
body: string;
benefits?: string[];
upcomingLabel?: string;
upcomingBenefits?: string[];
benefitPlacement?: 'copy' | 'aside';
metaLabel?: string;
modelSlot?: ReactNode;
actionLabel?: string;
selected: boolean;
badge?: string;
statusSlot?: ReactNode;
featured?: boolean;
variant?: 'amr';
onClick: () => void;
}) {
function handleKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
if (event.target !== event.currentTarget) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onClick();
}
const hasBenefits =
(benefits && benefits.length > 0) ||
(upcomingBenefits && upcomingBenefits.length > 0);
const benefitStack = hasBenefits ? (
<span className="onboarding-view__benefit-stack">
{benefits && benefits.length > 0 ? (
<span className="onboarding-view__benefits">
{benefits.map((item, index) => (
<span
key={item}
className={`onboarding-view__benefit${
index >= 2 ? ' onboarding-view__benefit--hero' : ''
}`}
>
{item}
</span>
))}
</span>
) : null}
{upcomingBenefits && upcomingBenefits.length > 0 ? (
<span className="onboarding-view__upcoming-benefits">
{upcomingLabel ? (
<span className="onboarding-view__upcoming-label">{upcomingLabel}</span>
) : null}
{upcomingBenefits.map((item) => (
<span key={item} className="onboarding-view__benefit onboarding-view__benefit--upcoming">
{item}
</span>
))}
</span>
) : null}
</span>
) : null;
const modelUnderLogo = variant === 'amr' && modelSlot;
const iconNode = (
<span
className={
'onboarding-view__icon' +
(agentIconId ? ' onboarding-view__icon--asset' : '')
}
>
{agentIconId ? (
<AgentIcon
id={agentIconId}
size={featured ? 52 : 40}
className="onboarding-view__agent-logo"
/>
) : (
<Icon name={icon} size={18} />
)}
</span>
);
const copyNode = (
<span className="onboarding-view__card-copy">
<span className="onboarding-view__card-top">
<strong>{title}</strong>
{badge ? <span className="onboarding-view__badge">{badge}</span> : null}
</span>
{metaLabel ? <span className="onboarding-view__card-meta">{metaLabel}</span> : null}
{modelUnderLogo ? null : modelSlot}
{benefitPlacement === 'copy' && benefitStack ? (
benefitStack
) : !modelSlot ? (
<small>{body}</small>
) : null}
</span>
);
return (
<div
role="button"
tabIndex={0}
className={`onboarding-view__card${selected ? ' is-selected' : ''}${
featured ? ' onboarding-view__card--featured' : ''
}${variant ? ` onboarding-view__card--${variant}` : ''}${
benefitPlacement === 'aside' ? ' onboarding-view__card--benefit-aside' : ''
}`}
onClick={onClick}
onKeyDown={handleKeyDown}
aria-pressed={selected}
>
{variant === 'amr' ? (
<span className="onboarding-view__identity">
{iconNode}
{copyNode}
</span>
) : (
<>
{iconNode}
{copyNode}
</>
)}
{modelUnderLogo ? (
<span className="onboarding-view__card-model">
{modelSlot}
</span>
) : null}
{benefitPlacement === 'aside' && benefitStack ? (
<span className="onboarding-view__benefit-aside">{benefitStack}</span>
) : null}
{statusSlot ? (
<span className="onboarding-view__card-status">
{statusSlot}
</span>
) : null}
{actionLabel ? <span className="onboarding-view__card-action">{actionLabel}</span> : null}
{selected ? (
<span className="onboarding-view__check">
<Icon name="check" size={14} />
</span>
) : null}
</div>
);
}