// 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 = [ { 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 | 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; 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; onProviderModelsCacheChange?: Dispatch>>; 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; onRefreshAgents: () => Promise | 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; autoSendFirstMessage?: boolean; pendingFiles?: File[]; }, ) => Promise | boolean | void; onCreatePluginShareProject: ( pluginId: string, action: PluginShareAction, locale?: string, ) => Promise; onImportClaudeDesign: ( file: File, ) => Promise | ImportClaudeDesignOutcome | void; onImportFolder?: (baseDir: string) => Promise | void; onImportFolderResponse?: (response: OpenDesignHostProjectImportSuccess) => Promise | void; onOpenProject: (id: string) => void; onOpenLiveArtifact: (projectId: string, artifactId: string) => void; onDeleteProject: (id: string) => Promise | 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; onPersistComposioKey: (composio: AppConfig['composio']) => Promise | 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(null); const [newProjectOpen, setNewProjectOpen] = useState(false); const [newProjectInitialTab, setNewProjectInitialTab] = useState('prototype'); const [integrationTab, setIntegrationTab] = useState(integrationInitialTab); const [homePromptHandoff, setHomePromptHandoff] = useState(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 = ( ); if (view === 'onboarding') { return (
); } return (
openNewProject()} />
Join Discord
{avatarMenu}
{view === 'home' ? ( 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 ? ( ) : (

{t('entry.navProjects')}

openNewProject()} />
) ) : null} {view === 'tasks' ? ( ) : null} {view === 'plugins' ? ( ) : null} {view === 'design-systems' ? ( designSystemsLoading ? ( ) : (

{t('entry.navDesignSystems')}

setPreviewSystemId(id)} />
) ) : null} {view === 'integrations' ? ( ) : null}
{previewSystem ? ( setPreviewSystemId(null)} /> ) : null} { setNewProjectOpen(false); openIntegrationTab('connectors'); }} onClose={() => setNewProjectOpen(false)} />
); } function OnboardingView({ config, providerModelsCache: sharedProviderModelsCache, onProviderModelsCacheChange, agents, daemonLive, onModeChange, onAgentChange, onAgentModelChange, onApiProtocolChange, onApiModelChange, onConfigPersist, onRefreshAgents, renderDesignSystemCreation, onFinish, }: { config: AppConfig; providerModelsCache?: Record; onProviderModelsCacheChange?: Dispatch>>; 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; onRefreshAgents: () => Promise | 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(null); const [amrLoginPending, setAmrLoginPending] = useState(false); const [amrLoginError, setAmrLoginError] = useState(false); const [visibleAgentIds, setVisibleAgentIds] = useState([]); 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 >({}); 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>>([]); 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(''); 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(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[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) { 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 { 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 (
{t('settings.welcomeKicker') ? ( {t('settings.welcomeKicker')} ) : null}

{t('settings.welcomeTitle')}

{t('settings.welcomeSubtitle') ?

{t('settings.welcomeSubtitle')}

: null}
    {steps.map((label, index) => (
  1. {index + 1}
  2. ))}
{step === 0 ? (
{showAmrCloudOption ? (
0 ? ( onAgentModelChange('amr', { model })} /> ) : null } variant="amr" featured selected={runtime === 'amr'} onClick={() => { setRuntime('amr'); onModeChange('daemon'); onAgentChange('amr'); }} />
) : null}
{runtimeItems.map((item) => ( ))}
{runtime === 'local' ? ( void scanCliAgents()} onSelectAgent={(agentId) => { onModeChange('daemon'); onAgentChange(agentId); }} onSelectModel={(model) => { if (!selectedAgent) return; onAgentModelChange(selectedAgent.id, { model }); }} /> ) : null} {runtime === 'byok' ? ( 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}
) : null} {step === 1 ? (
{ if (typeof value === 'string' && value) { emitOnboardingClick('role', 'select_option', { role: value, }); } setProfile((current) => ({ ...current, role: value })); }} /> { if (typeof value === 'string' && value) { emitOnboardingClick('organization_size', 'select_option', { organization_size: value, }); } setProfile((current) => ({ ...current, orgSize: 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 })); }} /> { if (typeof value === 'string' && value) { emitOnboardingClick('hear_about_us', 'select_option', { discovery_source: value, }); } setProfile((current) => ({ ...current, source: value })); }} />
) : null} {step === 2 && renderDesignSystemCreation ? (
{t('settings.onboardingDesignIntroGenerateTitle')} {t('settings.onboardingDesignIntroGenerateBody')}
{t('settings.onboardingDesignIntroReuseTitle')} {t('settings.onboardingDesignIntroReuseBody')}
{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; }, })}
) : null} {step === 2 && !renderDesignSystemCreation ? (
{designItems.map((item) => ( ))}
) : null} {step === 2 && renderDesignSystemCreation ? null : (
{step === 0 && amrLoginError ? ( {t('settings.amrLoginErrorCompact')} ) : null}
)}
); } 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 (
{t('settings.localCli')}

{daemonLive ? t('settings.codeAgentHint') : t('settings.modeDaemonOffline')}

{scanning ? (

{t('settings.rescanRunning')}

{t('settings.onboardingCliScanHint')}

) : null} {agents.length > 0 ? (
{agents.map((agent, index) => ( ))}
) : null} {showEmpty ? (
{t('settings.noAgentsDetected')}
) : null} {selectedAgent && modelOptions.length > 0 ? ( ) : null}
); } function OnboardingAmrModelSelect({ models, modelsSource, selectedModel, onSelectModel, }: { models: NonNullable; 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 ( ); } function formatOnboardingAmrModelLabel( model: NonNullable[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 = { 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 (
{t('settings.modeApiMeta')}

{t('settings.modeApi')}

{API_PROTOCOL_TABS.map((tab) => ( ))}
{modelOptions.length > 0 ? ( ) : ( )}
{modelsState.status === 'running' ? (

{t('settings.fetchModelsRunning')}

) : modelsState.status === 'done' ? (

{renderOnboardingProviderModelsMessage(t, modelsState.result)}

) : null} {testState.status === 'running' ? (

{t('settings.testRunning')}

) : testState.status === 'done' ? (

{renderOnboardingProviderTestMessage(t, testState.result, model)}

) : null}
); } 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(); 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, 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, 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 (

{title}

{body}

); } 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(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 (
{label} {open ? (
{options.map((option) => { const selected = selectedValues.includes(option.value); return ( ); })}
) : null}
); } 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) { 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 ? ( {benefits && benefits.length > 0 ? ( {benefits.map((item, index) => ( = 2 ? ' onboarding-view__benefit--hero' : '' }`} > {item} ))} ) : null} {upcomingBenefits && upcomingBenefits.length > 0 ? ( {upcomingLabel ? ( {upcomingLabel} ) : null} {upcomingBenefits.map((item) => ( {item} ))} ) : null} ) : null; const modelUnderLogo = variant === 'amr' && modelSlot; const iconNode = ( {agentIconId ? ( ) : ( )} ); const copyNode = ( {title} {badge ? {badge} : null} {metaLabel ? {metaLabel} : null} {modelUnderLogo ? null : modelSlot} {benefitPlacement === 'copy' && benefitStack ? ( benefitStack ) : !modelSlot ? ( {body} ) : null} ); return (
{variant === 'amr' ? ( {iconNode} {copyNode} ) : ( <> {iconNode} {copyNode} )} {modelUnderLogo ? ( {modelSlot} ) : null} {benefitPlacement === 'aside' && benefitStack ? ( {benefitStack} ) : null} {statusSlot ? ( {statusSlot} ) : null} {actionLabel ? {actionLabel} : null} {selected ? ( ) : null}
); }