mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
* 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
2718 lines
97 KiB
TypeScript
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>
|
|
);
|
|
}
|