import { useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import WizardProgress from './components/WizardProgress';
import { useApi } from '../../hooks/useApi';
import { api } from '../../api/client';
import type { CodingAgentBackend, CliVerificationResult, AgentAuthMethod, AgentRegistrationPayload } from '../../types';
const TOTAL_STEPS = 5;
const STEP_LABELS = [
'Select Backend',
'Verify CLI',
'Authentication',
'Workspaces',
'Finalize',
];
export interface WizardState {
currentStep: number;
backendType: string | null;
cliVerified: boolean;
cliPath: string | null;
authConfigured: boolean;
authCredentials: string | null;
workspaces: string[];
alias: string;
endpoint: string;
timeoutSecs: number | null;
costCapUsd: number | null;
}
const INITIAL_STATE: WizardState = {
currentStep: 1,
backendType: null,
cliVerified: false,
cliPath: null,
authConfigured: false,
authCredentials: null,
workspaces: [],
alias: '',
endpoint: '',
timeoutSecs: null,
costCapUsd: null,
};
function hasDataEntered(state: WizardState): boolean {
return (
state.backendType !== null ||
state.cliVerified ||
state.authConfigured ||
state.workspaces.length > 0 ||
state.alias !== '' ||
state.endpoint !== '' ||
state.timeoutSecs !== null ||
state.costCapUsd !== null ||
state.cliPath !== null ||
state.authCredentials !== null
);
}
/** Step 2: CLI Verification sub-component */
function StepCliVerification({
backendType,
backends,
onVerified,
}: {
backendType: string;
backends: CodingAgentBackend[];
onVerified: (path: string | null) => void;
}) {
const [checking, setChecking] = useState(false);
const [result, setResult] = useState<CliVerificationResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [installing, setInstalling] = useState(false);
const [installOutput, setInstallOutput] = useState<string | null>(null);
const [installSuccess, setInstallSuccess] = useState(false);
const backend = backends.find((b) => b.agent_type === backendType) ?? null;
const runVerification = useCallback(async () => {
setChecking(true);
setError(null);
setResult(null);
try {
const res = await api.verifyCli(backendType);
if (res.ok && res.data) {
setResult(res.data);
if (res.data.installed) {
onVerified(res.data.path ?? null);
}
} else if (!res.ok) {
setError(res.message || 'Verification request failed');
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Network error');
} finally {
setChecking(false);
}
}, [backendType, onVerified]);
// Auto-run verification when the step is first reached
useEffect(() => {
runVerification();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const runInstall = useCallback(async () => {
setInstalling(true);
setInstallOutput(null);
setInstallSuccess(false);
try {
const res = await fetch('/ui/api/coding-agents/onboarding/run-install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ agent_type: backendType }),
});
const data = await res.json();
if (data.ok && data.success) {
setInstallOutput(data.output || 'Installation completed successfully.');
setInstallSuccess(true);
} else if (data.ok && !data.success) {
setInstallOutput(data.output || 'Installation failed.');
setInstallSuccess(false);
} else {
setInstallOutput(data.message || 'Installation request failed.');
setInstallSuccess(false);
}
} catch (e) {
setInstallOutput(e instanceof Error ? e.message : 'Network error during installation.');
setInstallSuccess(false);
} finally {
setInstalling(false);
}
}, [backendType, runVerification]);
if (checking) {
return (
<div className="py-8 flex flex-col items-center gap-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-accent)]" />
<p className="text-sm text-gray-500">Checking CLI installation…</p>
</div>
);
}
if (error) {
return (
<div className="py-6">
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<p className="text-sm text-red-700">{error}</p>
</div>
<button
type="button"
onClick={runVerification}
className="px-4 py-2 text-sm font-medium bg-[var(--color-accent)] text-white rounded-lg hover:bg-[var(--color-accent-hover)]"
>
Re-check
</button>
</div>
);
}
if (result && result.installed) {
return (
<div className="py-6">
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center gap-2">
<svg className="h-5 w-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<p className="text-sm font-medium text-green-700">CLI is installed</p>
</div>
{result.version && (
<p className="text-sm text-green-600 mt-1 ml-7">Version: {result.version}</p>
)}
{result.path && (
<p className="text-xs text-green-500 mt-0.5 ml-7">Path: {result.path}</p>
)}
</div>
</div>
);
}
if (result && !result.installed) {
const installCmd = backend?.install_instructions ?? '';
return (
<div className="py-6">
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
<div className="flex items-center gap-2 mb-2">
<svg className="h-5 w-5 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm font-medium text-yellow-700">CLI not found</p>
</div>
{installCmd && (
<div className="ml-7">
<p className="text-sm text-gray-700 mb-2">Install command:</p>
<pre className="bg-gray-900 text-green-400 rounded-lg px-4 py-3 text-sm font-mono overflow-x-auto">
{installCmd}
</pre>
</div>
)}
</div>
{/* Install output display */}
{installOutput !== null && (
<div className="mb-4">
<p className="text-xs font-medium text-gray-600 mb-1">
{installing ? 'Installing…' : installSuccess ? '✅ Installation complete' : '❌ Installation failed'}
</p>
<pre className="bg-gray-900 text-gray-100 rounded-lg px-4 py-3 text-xs font-mono max-h-48 overflow-y-auto whitespace-pre-wrap">
{installOutput}
</pre>
</div>
)}
<div className="flex gap-3">
{installCmd && !installSuccess && (
<button
type="button"
onClick={runInstall}
disabled={installing}
className={`px-4 py-2 text-sm font-medium rounded-lg ${
installing
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'text-white bg-green-600 hover:bg-green-700'
}`}
>
{installing ? '⏳ Installing…' : '📦 Install'}
</button>
)}
{installSuccess && (
<button
type="button"
onClick={runVerification}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
>
🔌 Activate & Test
</button>
)}
{!installSuccess && (
<button
type="button"
onClick={runVerification}
disabled={installing}
className="px-4 py-2 text-sm font-medium bg-[var(--color-accent)] text-white rounded-lg hover:bg-[var(--color-accent-hover)] disabled:opacity-50"
>
Re-check
</button>
)}
</div>
</div>
);
}
return null;
}
/** Step 3: Authentication sub-component */
function StepAuthentication({
authMethod,
onComplete,
}: {
authMethod: AgentAuthMethod;
onComplete: (credentials?: string) => void;
}) {
const [apiKey, setApiKey] = useState('');
// Auto-advance when auth method is 'none'
useEffect(() => {
if (authMethod.type === 'none') {
onComplete();
}
}, [authMethod, onComplete]);
if (authMethod.type === 'none') {
return (
<div className="py-8 text-center text-gray-500">
<p>No authentication required. Advancing automatically...</p>
</div>
);
}
if (authMethod.type === 'apiKey') {
return (
<div className="space-y-4">
<label className="block text-sm font-medium text-gray-700">
API Key ({authMethod.env_var})
</label>
<input
type="text"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={`Enter your ${authMethod.env_var} value`}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-[var(--color-accent)]"
/>
<button
type="button"
onClick={() => {
if (apiKey.trim()) {
onComplete(apiKey.trim());
}
}}
disabled={!apiKey.trim()}
className={`px-4 py-2 text-sm font-medium rounded-lg ${
apiKey.trim()
? 'bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-hover)]'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Save API Key
</button>
</div>
);
}
if (authMethod.type === 'oAuth') {
return (
<div className="space-y-4">
<p className="text-sm text-gray-600">
This backend requires OAuth authentication. Click the button below to initiate the OAuth flow.
</p>
<button
type="button"
onClick={() => {
// Placeholder: in a real implementation this would open the OAuth URL
onComplete();
}}
className="px-4 py-2 text-sm font-medium bg-[var(--color-accent)] text-white rounded-lg hover:bg-[var(--color-accent-hover)]"
>
Initiate OAuth Flow
</button>
</div>
);
}
if (authMethod.type === 'cliLogin') {
return (
<div className="space-y-4">
<p className="text-sm text-gray-600">
Run the following command in your terminal to authenticate:
</p>
<pre className="bg-gray-900 text-green-400 px-4 py-3 rounded-lg text-sm font-mono overflow-x-auto">
{authMethod.command}
</pre>
<button
type="button"
onClick={() => onComplete()}
className="px-4 py-2 text-sm font-medium bg-[var(--color-accent)] text-white rounded-lg hover:bg-[var(--color-accent-hover)]"
>
I've completed the login
</button>
</div>
);
}
return null;
}
/** Step 4: Workspace Selection sub-component */
function StepWorkspaceSelection({
selectedWorkspaces,
onSelect,
}: {
selectedWorkspaces: string[];
onSelect: (dirs: string[]) => void;
}) {
const { data: directories, loading, error, refetch } = useApi<string[]>(
() => api.listDirectories(),
[]
);
const handleToggle = (dir: string) => {
if (selectedWorkspaces.includes(dir)) {
onSelect(selectedWorkspaces.filter((d) => d !== dir));
} else {
onSelect([...selectedWorkspaces, dir]);
}
};
if (loading) {
return (
<div className="py-8 text-center text-gray-500">
<div className="animate-pulse space-y-3">
<div className="h-4 bg-gray-200 rounded w-3/4 mx-auto"></div>
<div className="h-4 bg-gray-200 rounded w-2/3 mx-auto"></div>
<div className="h-4 bg-gray-200 rounded w-1/2 mx-auto"></div>
</div>
<p className="mt-4 text-sm">Loading directories…</p>
</div>
);
}
if (error) {
return (
<div className="py-8 text-center">
<p className="text-red-600 mb-4">{error}</p>
<button
type="button"
onClick={refetch}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
>
Retry
</button>
</div>
);
}
if (!directories || directories.length === 0) {
return (
<div className="py-8 text-center text-gray-500">
<p>No directories available.</p>
</div>
);
}
return (
<div className="space-y-2">
<p className="text-sm text-gray-600 mb-4">
Select one or more workspace directories for this agent.
</p>
<div className="max-h-64 overflow-y-auto border border-gray-200 rounded-lg p-2 space-y-1">
{directories.map((dir) => (
<label
key={dir}
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedWorkspaces.includes(dir)}
onChange={() => handleToggle(dir)}
className="h-4 w-4 rounded border-gray-300 text-[var(--color-accent)] focus:ring-[var(--color-accent)]"
/>
<span className="text-sm text-gray-700 font-mono">{dir}</span>
</label>
))}
</div>
{selectedWorkspaces.length === 0 && (
<p className="text-xs text-amber-600 mt-2">
Please select at least one workspace to continue.
</p>
)}
</div>
);
}
/** Step 5: Finalize sub-component */
function StepFinalize({
state,
backends,
onAliasChange,
onTimeoutChange,
onCostCapChange,
submitting,
submitError,
}: {
state: WizardState;
backends: CodingAgentBackend[];
onAliasChange: (alias: string) => void;
onTimeoutChange: (timeout: number | null) => void;
onCostCapChange: (costCap: number | null) => void;
submitting: boolean;
submitError: string | null;
}) {
const selectedBackend = backends.find((b) => b.agent_type === state.backendType);
return (
<div className="space-y-6">
{/* Configuration Summary */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">Configuration Summary</h4>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-500">Backend Type</span>
<span className="font-medium text-gray-900">
{selectedBackend?.display_name ?? state.backendType ?? '—'}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">CLI Verified</span>
<span className={`font-medium ${state.cliVerified ? 'text-green-700' : 'text-red-700'}`}>
{state.cliVerified ? 'Yes' : 'No'}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Auth Configured</span>
<span className={`font-medium ${state.authConfigured ? 'text-green-700' : 'text-gray-500'}`}>
{state.authConfigured ? 'Yes' : 'Not required'}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Workspaces</span>
<span className="font-medium text-gray-900">
{state.workspaces.length} selected
</span>
</div>
{state.workspaces.length > 0 && (
<div className="pt-1 pl-4">
<ul className="text-xs text-gray-600 space-y-0.5 font-mono">
{state.workspaces.map((ws) => (
<li key={ws}>{ws}</li>
))}
</ul>
</div>
)}
</div>
</div>
{/* Alias and Optional Settings */}
<div className="space-y-4">
<div>
<label htmlFor="agent-alias" className="block text-sm font-medium text-gray-700 mb-1">
Agent Alias
</label>
<input
id="agent-alias"
type="text"
value={state.alias}
onChange={(e) => onAliasChange(e.target.value)}
placeholder="e.g. my-claude-agent"
disabled={submitting}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-[var(--color-accent)]"
/>
</div>
<div>
<label htmlFor="agent-timeout" className="block text-sm font-medium text-gray-700 mb-1">
Timeout (seconds) <span className="text-gray-400 font-normal">— optional</span>
</label>
<input
id="agent-timeout"
type="number"
min={0}
value={state.timeoutSecs ?? ''}
onChange={(e) => {
const val = e.target.value;
onTimeoutChange(val === '' ? null : Number(val));
}}
placeholder="e.g. 300"
disabled={submitting}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-[var(--color-accent)]"
/>
</div>
<div>
<label htmlFor="agent-cost-cap" className="block text-sm font-medium text-gray-700 mb-1">
Cost Cap (USD) <span className="text-gray-400 font-normal">— optional</span>
</label>
<input
id="agent-cost-cap"
type="number"
min={0}
step="0.01"
value={state.costCapUsd ?? ''}
onChange={(e) => {
const val = e.target.value;
onCostCapChange(val === '' ? null : Number(val));
}}
placeholder="e.g. 5.00"
disabled={submitting}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-[var(--color-accent)]"
/>
</div>
</div>
{/* Error display */}
{submitError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm text-red-700">{submitError}</p>
</div>
)}
</div>
);
}
export default function OnboardingWizard() {
const navigate = useNavigate();
const [state, setState] = useState<WizardState>(INITIAL_STATE);
const { data: backends, loading: backendsLoading } = useApi<CodingAgentBackend[]>(
() => api.codingAgentBackends(),
[]
);
const selectedBackend = backends?.find(
(b) => b.agent_type === state.backendType
) ?? null;
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const goNext = useCallback(() => {
setState((prev) => ({
...prev,
currentStep: Math.min(prev.currentStep + 1, TOTAL_STEPS),
}));
}, []);
const goBack = useCallback(() => {
setState((prev) => ({
...prev,
currentStep: Math.max(prev.currentStep - 1, 1),
}));
}, []);
const handleCancel = useCallback(() => {
if (hasDataEntered(state)) {
const confirmed = window.confirm(
'You have unsaved changes. Are you sure you want to cancel?'
);
if (!confirmed) return;
}
navigate('/ui/coding-agents');
}, [state, navigate]);
const handleBackendSelect = useCallback((agentType: string) => {
setState((prev) => ({
...prev,
backendType: agentType,
currentStep: 2,
}));
}, []);
const handleCliVerified = useCallback((path: string | null) => {
setState((prev) => ({ ...prev, cliVerified: true, cliPath: path }));
}, []);
const handleAuthComplete = useCallback((credentials?: string) => {
setState((prev) => ({ ...prev, authConfigured: true, authCredentials: credentials ?? null }));
goNext();
}, [goNext]);
const handleSubmit = useCallback(async () => {
setSubmitting(true);
setSubmitError(null);
try {
// Build transport config from wizard data
const transport = state.cliPath ? {
type: 'stdio' as const,
command: state.cliPath,
args: ['acp'],
env: state.authCredentials && selectedBackend?.auth_method?.type === 'apiKey'
? { [(selectedBackend.auth_method as { env_var: string }).env_var]: state.authCredentials }
: {},
} : undefined;
const payload: AgentRegistrationPayload = {
backend_type: state.backendType ?? '',
alias: state.alias,
endpoint: transport ? '' : (state.endpoint || `acp://${state.backendType}`),
workspaces: state.workspaces,
timeout_secs: state.timeoutSecs,
cost_cap_usd: state.costCapUsd,
auth: state.authCredentials ? { credentials: state.authCredentials } : null,
};
// Include transport in the registration request
const res = await api.registerCodingAgent(payload, transport);
if (res.ok) {
navigate('/ui/coding-agents');
} else {
setSubmitError(res.message || 'Registration failed. Please try again.');
}
} catch (e) {
setSubmitError(e instanceof Error ? e.message : 'Network error. Please try again.');
} finally {
setSubmitting(false);
}
}, [state, selectedBackend, navigate]);
// Determine if the Next button should be disabled for the current step
const isNextDisabled =
(state.currentStep === 2 && !state.cliVerified) ||
(state.currentStep === 4 && state.workspaces.length === 0);
return (
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-semibold">Add Coding Agent</h2>
<button
type="button"
onClick={handleCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
>
Cancel
</button>
</div>
<div className="mb-8">
<WizardProgress currentStep={state.currentStep} totalSteps={TOTAL_STEPS} />
</div>
<div className="bg-white rounded-xl shadow-sm p-6">
<h3 className="text-lg font-semibold mb-4">
Step {state.currentStep}: {STEP_LABELS[state.currentStep - 1]}
</h3>
{/* Step content */}
<div className="py-4">
{state.currentStep === 1 && (
<div>
{backendsLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="bg-gray-100 border rounded-lg p-4 animate-pulse h-24"
/>
))}
</div>
) : backends && backends.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{backends.map((backend) => (
<button
key={backend.agent_type}
type="button"
onClick={() => handleBackendSelect(backend.agent_type)}
className={`bg-white border rounded-lg p-4 cursor-pointer hover:border-[var(--color-accent)] text-left transition-colors ${
state.backendType === backend.agent_type
? 'border-[var(--color-accent)] bg-blue-50'
: 'border-gray-200'
}`}
>
<div className="font-medium text-gray-900">
{backend.display_name}
</div>
<div className="text-sm text-gray-500 mt-1">
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">
{backend.cli_command}
</code>
</div>
</button>
))}
</div>
) : (
<p className="text-center text-gray-500">No backends available.</p>
)}
</div>
)}
{state.currentStep === 2 && state.backendType && backends ? (
<StepCliVerification
backendType={state.backendType}
backends={backends}
onVerified={handleCliVerified}
/>
) : state.currentStep === 2 && !state.backendType ? (
<p className="text-center text-gray-500">Please go back and select a backend type first.</p>
) : null}
{state.currentStep === 3 && (
selectedBackend ? (
<StepAuthentication
authMethod={selectedBackend.auth_method}
onComplete={handleAuthComplete}
/>
) : (
<p className="text-center text-gray-500">Please select a backend type first.</p>
)
)}
{state.currentStep === 4 && (
<StepWorkspaceSelection
selectedWorkspaces={state.workspaces}
onSelect={(dirs) => setState((prev) => ({ ...prev, workspaces: dirs }))}
/>
)}
{state.currentStep === 5 && (
<StepFinalize
state={state}
backends={backends ?? []}
onAliasChange={(alias) => setState((prev) => ({ ...prev, alias }))}
onTimeoutChange={(timeoutSecs) => setState((prev) => ({ ...prev, timeoutSecs }))}
onCostCapChange={(costCapUsd) => setState((prev) => ({ ...prev, costCapUsd }))}
submitting={submitting}
submitError={submitError}
/>
)}
</div>
{/* Navigation buttons */}
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
<button
type="button"
onClick={goBack}
disabled={state.currentStep === 1}
className={`px-4 py-2 text-sm font-medium rounded-lg ${
state.currentStep === 1
? 'text-gray-400 bg-gray-50 cursor-not-allowed'
: 'text-gray-700 bg-gray-100 hover:bg-gray-200'
}`}
>
Back
</button>
{state.currentStep < TOTAL_STEPS ? (
<button
type="button"
onClick={goNext}
disabled={isNextDisabled}
className={`px-4 py-2 text-sm font-medium rounded-lg ${
isNextDisabled
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-hover)]'
}`}
>
Next
</button>
) : (
<button
type="button"
onClick={handleSubmit}
disabled={submitting}
className={`px-4 py-2 text-sm font-medium rounded-lg ${
submitting
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-hover)]'
}`}
>
{submitting ? 'Registering…' : 'Register Agent'}
</button>
)}
</div>
</div>
</div>
);
}