React 19 runtime migration complete, AI-assisted migration underway

This commit is contained in:
Richard Osborne
2025-12-20 23:32:50 +01:00
parent 7d307066d8
commit 03a464f6ff
22 changed files with 3081 additions and 145 deletions

View File

@@ -58,6 +58,8 @@
]
},
"dependencies": {
"@anthropic-ai/sdk": "^0.71.2",
"@babel/parser": "^7.28.5",
"@electron/remote": "^2.1.3",
"@jaames/iro": "^5.5.2",
"@microlink/react-json-view": "^1.27.0",

View File

@@ -0,0 +1,291 @@
/**
* AI Migration Orchestrator
*
* Coordinates AI-assisted migration of multiple components with
* retry logic, verification, and user decision points.
*
* @module migration/AIMigrationOrchestrator
*/
import { ClaudeClient, type AIPreferences } from '../../utils/migration/claudeClient';
import { BudgetController, type BudgetState } from './BudgetController';
import type { ComponentMigrationInfo } from './types';
export interface OrchestratorConfig {
maxRetries: number;
minConfidence: number;
verifyMigration: boolean;
}
export interface ComponentMigrationResult {
componentId: string;
componentName: string;
status: 'success' | 'partial' | 'failed' | 'skipped';
migratedCode?: string;
changes: string[];
warnings: string[];
attempts: number;
totalCost: number;
error?: string;
aiSuggestion?: string;
}
export interface ProgressUpdate {
phase: string;
component: string;
attempt: number;
message: string;
}
export interface DecisionRequest {
componentId: string;
componentName: string;
attempts: number;
attemptHistory: AttemptRecord[];
costSpent: number;
retryCost: number;
}
export interface Decision {
action: 'retry' | 'skip' | 'getHelp' | 'manual';
}
interface AttemptRecord {
code: string | null;
error: string;
cost: number;
}
export class AIMigrationOrchestrator {
private client: ClaudeClient;
private budget: BudgetController;
private config: OrchestratorConfig;
private aborted = false;
constructor(
apiKey: string,
budgetConfig: { maxPerSession: number; pauseIncrement: number },
config: OrchestratorConfig,
onBudgetPause: (state: BudgetState) => Promise<boolean>
) {
this.client = new ClaudeClient(apiKey);
this.budget = new BudgetController(budgetConfig, onBudgetPause);
this.config = config;
}
async migrateComponent(
component: ComponentMigrationInfo,
code: string,
preferences: AIPreferences,
onProgress: (update: ProgressUpdate) => void,
onDecisionRequired: (request: DecisionRequest) => Promise<Decision>
): Promise<ComponentMigrationResult> {
let attempts = 0;
let totalCost = 0;
let lastError: string | null = null;
let lastCode: string | null = null;
const attemptHistory: AttemptRecord[] = [];
while (attempts < this.config.maxRetries && !this.aborted) {
attempts++;
// Check budget
const estimatedCost = this.client.estimateCost(code.length);
const budgetCheck = this.budget.checkBudget(estimatedCost);
if (!budgetCheck.allowed) {
return {
componentId: component.id,
componentName: component.name,
status: 'failed',
changes: [],
warnings: [],
attempts,
totalCost,
error: 'Budget exceeded'
};
}
if (budgetCheck.requiresApproval) {
const approved = await this.budget.requestApproval(estimatedCost);
if (!approved) {
return {
componentId: component.id,
componentName: component.name,
status: 'skipped',
changes: [],
warnings: ['Migration paused by user'],
attempts,
totalCost
};
}
}
// Attempt migration
onProgress({
phase: 'ai-migrating',
component: component.name,
attempt: attempts,
message: attempts === 1 ? 'Analyzing code patterns...' : `Retry attempt ${attempts}...`
});
const response = await this.client.migrateComponent({
code,
issues: component.issues,
componentName: component.name,
preferences,
previousAttempt: lastError ? { code: lastCode, error: lastError } : undefined
});
totalCost += response.cost;
this.budget.recordSpend(response.cost);
if (response.success && response.confidence >= this.config.minConfidence) {
// Verify the migration if enabled
if (this.config.verifyMigration && response.code) {
const verification = await this.verifyMigration(response.code, component);
if (!verification.valid) {
lastError = verification.error || 'Verification failed';
lastCode = response.code;
attemptHistory.push({
code: response.code,
error: lastError,
cost: response.cost
});
continue;
}
}
return {
componentId: component.id,
componentName: component.name,
status: 'success',
migratedCode: response.code!,
changes: response.changes,
warnings: response.warnings,
attempts,
totalCost
};
}
// Migration failed or low confidence
lastError = response.reason || 'Low confidence migration';
lastCode = response.code;
attemptHistory.push({
code: response.code,
error: lastError,
cost: response.cost
});
}
// All retries exhausted - ask user what to do
const decision = await onDecisionRequired({
componentId: component.id,
componentName: component.name,
attempts,
attemptHistory,
costSpent: totalCost,
retryCost: this.client.estimateCost(code.length)
});
switch (decision.action) {
case 'retry':
// Recursive retry with fresh attempts
return this.migrateComponent(component, code, preferences, onProgress, onDecisionRequired);
case 'skip':
return {
componentId: component.id,
componentName: component.name,
status: 'skipped',
changes: [],
warnings: ['Skipped by user after failed attempts'],
attempts,
totalCost
};
case 'getHelp': {
const help = await this.client.getHelp({
originalCode: code,
attempts,
attemptHistory
});
totalCost += 0.02; // Approximate cost for help request
return {
componentId: component.id,
componentName: component.name,
status: 'failed',
changes: [],
warnings: attemptHistory.map((a) => a.error),
attempts,
totalCost,
aiSuggestion: help
};
}
case 'manual':
return {
componentId: component.id,
componentName: component.name,
status: 'partial',
migratedCode: lastCode || undefined,
changes: [],
warnings: ['Marked for manual review'],
attempts,
totalCost
};
}
}
private async verifyMigration(
code: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
component: ComponentMigrationInfo
): Promise<{ valid: boolean; error?: string }> {
// Basic syntax check using Babel
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const babel = require('@babel/parser');
babel.parse(code, {
sourceType: 'module',
plugins: ['jsx', 'typescript']
});
} catch (syntaxError: unknown) {
const err = syntaxError as { message?: string };
return {
valid: false,
error: `Syntax error: ${err.message || 'Unknown syntax error'}`
};
}
// Check that no forbidden patterns remain
const forbiddenPatterns = [
{ regex: /componentWillMount\s*\(/, name: 'componentWillMount' },
{ regex: /componentWillReceiveProps\s*\(/, name: 'componentWillReceiveProps' },
{ regex: /componentWillUpdate\s*\(/, name: 'componentWillUpdate' },
{ regex: /ref\s*=\s*["'][^"']+["']/, name: 'string ref' },
{ regex: /contextTypes\s*=/, name: 'legacy contextTypes' }
];
for (const pattern of forbiddenPatterns) {
if (pattern.regex.test(code)) {
return {
valid: false,
error: `Code still contains ${pattern.name}`
};
}
}
return { valid: true };
}
abort(): void {
this.aborted = true;
}
getBudgetState(): BudgetState {
return this.budget.getState();
}
}

View File

@@ -0,0 +1,86 @@
/**
* Budget Controller for AI Migration
*
* Manages spending limits and approval flow for AI-assisted migrations.
* Enforces hard limits and pause-and-approve at configurable increments.
*
* @module migration/BudgetController
*/
export interface BudgetState {
maxPerSession: number;
spent: number;
pauseIncrement: number;
nextPauseAt: number;
paused: boolean;
}
export interface BudgetCheckResult {
allowed: boolean;
requiresApproval: boolean;
currentSpent: number;
estimatedNext: number;
wouldExceedMax: boolean;
}
export class BudgetController {
private state: BudgetState;
private onPauseRequired: (state: BudgetState) => Promise<boolean>;
constructor(
config: { maxPerSession: number; pauseIncrement: number },
onPauseRequired: (state: BudgetState) => Promise<boolean>
) {
this.state = {
maxPerSession: config.maxPerSession,
spent: 0,
pauseIncrement: config.pauseIncrement,
nextPauseAt: config.pauseIncrement,
paused: false
};
this.onPauseRequired = onPauseRequired;
}
checkBudget(estimatedCost: number): BudgetCheckResult {
const wouldExceedMax = this.state.spent + estimatedCost > this.state.maxPerSession;
const wouldExceedPause = this.state.spent + estimatedCost > this.state.nextPauseAt;
return {
allowed: !wouldExceedMax,
requiresApproval: wouldExceedPause && !this.state.paused,
currentSpent: this.state.spent,
estimatedNext: estimatedCost,
wouldExceedMax
};
}
async requestApproval(estimatedCost: number): Promise<boolean> {
const check = this.checkBudget(estimatedCost);
if (check.wouldExceedMax) {
return false; // Hard limit, can't approve
}
if (check.requiresApproval) {
const approved = await this.onPauseRequired(this.state);
if (approved) {
this.state.nextPauseAt += this.state.pauseIncrement;
}
return approved;
}
return true;
}
recordSpend(amount: number): void {
this.state.spent += amount;
}
getState(): BudgetState {
return { ...this.state };
}
getRemainingBudget(): number {
return Math.max(0, this.state.maxPerSession - this.state.spent);
}
}

View File

@@ -62,10 +62,7 @@ export class MigrationSessionManager extends EventDispatcher {
/**
* Creates a new migration session for a project
*/
async createSession(
sourcePath: string,
projectName: string
): Promise<MigrationSessionState> {
async createSession(sourcePath: string, projectName: string): Promise<MigrationSessionState> {
// Generate unique session ID
const sessionId = `migration-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -74,9 +71,7 @@ export class MigrationSessionManager extends EventDispatcher {
// Only allow migration of React 17 projects
if (versionInfo.version !== 'react17' && versionInfo.version !== 'unknown') {
throw new Error(
`Project is already using ${versionInfo.version}. Migration not needed.`
);
throw new Error(`Project is already using ${versionInfo.version}. Migration not needed.`);
}
// Create session
@@ -120,7 +115,7 @@ export class MigrationSessionManager extends EventDispatcher {
confirm: ['scanning'],
scanning: ['report', 'failed'],
report: ['configureAi', 'migrating'], // Can skip AI config if no AI needed
configureAi: ['migrating'],
configureAi: ['migrating', 'report'], // Can go back to report (cancel)
migrating: ['complete', 'failed'],
complete: [], // Terminal state
failed: ['confirm'] // Can retry from beginning
@@ -140,9 +135,7 @@ export class MigrationSessionManager extends EventDispatcher {
const currentStep = this.session.step;
if (!this.canTransitionTo(currentStep, step)) {
throw new Error(
`Invalid transition from "${currentStep}" to "${step}"`
);
throw new Error(`Invalid transition from "${currentStep}" to "${step}"`);
}
const previousStep = this.session.step;
@@ -181,17 +174,14 @@ export class MigrationSessionManager extends EventDispatcher {
await this.transitionTo('scanning');
try {
const scan = await scanProjectForMigration(
this.session.source.path,
(progress, currentItem, stats) => {
this.notifyListeners('scanProgress', {
session: this.session,
progress,
currentItem,
stats
});
}
);
const scan = await scanProjectForMigration(this.session.source.path, (progress, currentItem, stats) => {
this.notifyListeners('scanProgress', {
session: this.session,
progress,
currentItem,
stats
});
});
this.session.scan = scan;
await this.transitionTo('report');
@@ -395,9 +385,7 @@ export class MigrationSessionManager extends EventDispatcher {
private getSuccessfulMigrationCount(): number {
// Count from log entries
return (
this.session?.progress?.log.filter((l) => l.level === 'success').length ?? 0
);
return this.session?.progress?.log.filter((l) => l.level === 'success').length ?? 0;
}
private getNeedsReviewCount(): number {
@@ -405,9 +393,7 @@ export class MigrationSessionManager extends EventDispatcher {
}
private getFailedCount(): number {
return (
this.session?.progress?.log.filter((l) => l.level === 'error').length ?? 0
);
return this.session?.progress?.log.filter((l) => l.level === 'error').length ?? 0;
}
private async executeCopyPhase(): Promise<void> {
@@ -551,10 +537,10 @@ export class MigrationSessionManager extends EventDispatcher {
try {
// Update project.json with migration metadata
const targetProjectJsonPath = `${this.session.target.path}/project.json`;
// Read existing project.json
const projectJson = await filesystem.readJson(targetProjectJsonPath) as Record<string, unknown>;
const projectJson = (await filesystem.readJson(targetProjectJsonPath)) as Record<string, unknown>;
// Add React 19 markers
projectJson.runtimeVersion = 'react19';
projectJson.migratedFrom = {
@@ -563,12 +549,9 @@ export class MigrationSessionManager extends EventDispatcher {
originalPath: this.session.source.path,
aiAssisted: this.session.ai?.enabled ?? false
};
// Write updated project.json back
await filesystem.writeFile(
targetProjectJsonPath,
JSON.stringify(projectJson, null, 2)
);
await filesystem.writeFile(targetProjectJsonPath, JSON.stringify(projectJson, null, 2));
this.addLogEntry({
level: 'success',
@@ -613,9 +596,7 @@ export const migrationSessionManager = new MigrationSessionManager();
/**
* Checks if a project needs migration
*/
export async function checkProjectNeedsMigration(
projectPath: string
): Promise<{
export async function checkProjectNeedsMigration(projectPath: string): Promise<{
needsMigration: boolean;
versionInfo: RuntimeVersionInfo;
}> {
@@ -647,14 +628,7 @@ export function getStepLabel(step: MigrationStep): string {
* Gets the step number for progress display (1-indexed)
*/
export function getStepNumber(step: MigrationStep): number {
const order: MigrationStep[] = [
'confirm',
'scanning',
'report',
'configureAi',
'migrating',
'complete'
];
const order: MigrationStep[] = ['confirm', 'scanning', 'report', 'configureAi', 'migrating', 'complete'];
const index = order.indexOf(step);
return index >= 0 ? index + 1 : 0;
}

View File

@@ -0,0 +1,198 @@
/**
* Claude API Client for Component Migration
*
* Handles communication with Anthropic's Claude API for
* AI-assisted React component migration.
*
* @module migration/claudeClient
*/
import type { MigrationIssue } from '../../models/migration/types';
import { MIGRATION_SYSTEM_PROMPT, HELP_PROMPT_TEMPLATE } from './claudePrompts';
export interface AIPreferences {
preferFunctional: boolean;
preserveComments: boolean;
verboseOutput: boolean;
}
export interface MigrationRequest {
code: string;
issues: MigrationIssue[];
componentName: string;
preferences: AIPreferences;
previousAttempt?: {
code: string | null;
error: string;
};
}
export interface MigrationResponse {
success: boolean;
code: string | null;
changes: string[];
warnings: string[];
confidence: number;
reason?: string;
suggestion?: string;
tokensUsed: {
input: number;
output: number;
};
cost: number;
}
export interface HelpRequest {
originalCode: string;
attempts: number;
attemptHistory: Array<{
code: string | null;
error: string;
}>;
}
export class ClaudeClient {
private client: any;
private model = 'claude-sonnet-4-20250514';
// Pricing per 1M tokens (as of Dec 2024)
private pricing = {
input: 3.0, // $3 per 1M input tokens
output: 15.0 // $15 per 1M output tokens
};
constructor(apiKey: string) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Anthropic = require('@anthropic-ai/sdk');
this.client = new Anthropic({
apiKey,
dangerouslyAllowBrowser: true // Safe in Electron - code runs locally, not in public browser
});
}
async migrateComponent(request: MigrationRequest): Promise<MigrationResponse> {
const userPrompt = this.buildUserPrompt(request);
const response = await this.client.messages.create({
model: this.model,
max_tokens: 4096,
system: MIGRATION_SYSTEM_PROMPT,
messages: [{ role: 'user', content: userPrompt }]
});
const tokensUsed = {
input: response.usage.input_tokens,
output: response.usage.output_tokens
};
const cost = this.calculateCost(tokensUsed);
// Parse the response
const content = response.content[0];
if (content.type !== 'text') {
throw new Error('Unexpected response type');
}
try {
const parsed = this.parseResponse(content.text);
return {
...parsed,
tokensUsed,
cost
};
} catch (parseError) {
return {
success: false,
code: null,
changes: [],
warnings: [],
confidence: 0,
reason: 'Failed to parse AI response',
suggestion: content.text.slice(0, 500), // Include raw response for debugging
tokensUsed,
cost
};
}
}
async getHelp(request: HelpRequest): Promise<string> {
const prompt = HELP_PROMPT_TEMPLATE.replace('{attempts}', String(request.attempts))
.replace('{originalCode}', request.originalCode)
.replace('{attemptHistory}', request.attemptHistory.map((a, i) => `Attempt ${i + 1}: ${a.error}`).join('\n'));
const response = await this.client.messages.create({
model: this.model,
max_tokens: 2048,
messages: [{ role: 'user', content: prompt }]
});
const content = response.content[0];
if (content.type !== 'text') {
throw new Error('Unexpected response type');
}
return content.text;
}
private buildUserPrompt(request: MigrationRequest): string {
let prompt = `Migrate this React component to React 19:\n\n`;
prompt += `Component: ${request.componentName}\n\n`;
prompt += `Issues detected:\n`;
request.issues.forEach((issue) => {
prompt += `- ${issue.type} at line ${issue.location.line}: ${issue.description}\n`;
});
prompt += `\nCode:\n\`\`\`javascript\n${request.code}\n\`\`\`\n`;
if (request.preferences.preferFunctional) {
prompt += `\nPreference: Convert to functional component with hooks if clean.\n`;
}
if (request.previousAttempt) {
prompt += `\n--- RETRY ---\n`;
prompt += `Previous attempt failed: ${request.previousAttempt.error}\n`;
if (request.previousAttempt.code) {
prompt += `Previous code:\n\`\`\`javascript\n${request.previousAttempt.code}\n\`\`\`\n`;
}
prompt += `Please try a different approach.\n`;
}
return prompt;
}
private parseResponse(text: string): Omit<MigrationResponse, 'tokensUsed' | 'cost'> {
// Try to extract JSON from the response
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('No JSON found in response');
}
const parsed = JSON.parse(jsonMatch[0]);
return {
success: parsed.success ?? false,
code: parsed.code ?? null,
changes: parsed.changes ?? [],
warnings: parsed.warnings ?? [],
confidence: parsed.confidence ?? 0,
reason: parsed.reason,
suggestion: parsed.suggestion
};
}
private calculateCost(tokens: { input: number; output: number }): number {
const inputCost = (tokens.input / 1_000_000) * this.pricing.input;
const outputCost = (tokens.output / 1_000_000) * this.pricing.output;
return inputCost + outputCost;
}
estimateCost(codeLength: number): number {
// Rough estimation: ~4 chars per token
const estimatedInputTokens = codeLength / 4 + 1000; // +1000 for system prompt
const estimatedOutputTokens = (codeLength / 4) * 1.5; // Output usually larger
return this.calculateCost({
input: estimatedInputTokens,
output: estimatedOutputTokens
});
}
}

View File

@@ -0,0 +1,112 @@
/**
* Claude AI Prompts for React 19 Migration
*
* System prompts and templates for guiding Claude to migrate
* React components from React 17 patterns to React 19.
*
* @module migration/claudePrompts
*/
export const MIGRATION_SYSTEM_PROMPT = `You are a React migration assistant for OpenNoodl, a visual programming platform. Your job is to migrate React class components from React 17 patterns to React 19.
## Your Task
Convert the provided React code to be compatible with React 19. The code may contain:
- componentWillMount (removed in React 19)
- componentWillReceiveProps (removed in React 19)
- componentWillUpdate (removed in React 19)
- UNSAFE_ prefixed lifecycle methods (removed in React 19)
- String refs (removed in React 19)
- Legacy context API (removed in React 19)
- React.createFactory (removed in React 19)
## Migration Rules
### Lifecycle Methods
1. componentWillMount → Move logic to componentDidMount or constructor
2. componentWillReceiveProps → Use getDerivedStateFromProps (static) or componentDidUpdate
3. componentWillUpdate → Use getSnapshotBeforeUpdate + componentDidUpdate
### String Refs
Convert: ref="myRef" → ref={this.myRef = React.createRef()} or useRef()
### Legacy Context
Convert contextTypes/childContextTypes/getChildContext → React.createContext
### Functional Preference
If the component doesn't use complex state or many lifecycle methods, prefer converting to a functional component with hooks.
## Output Format
You MUST respond with a JSON object in this exact format:
{
"success": true,
"code": "// The migrated code here",
"changes": [
"Converted componentWillMount to useEffect",
"Replaced string ref with useRef"
],
"warnings": [
"Verify the useEffect dependency array is correct"
],
"confidence": 0.85
}
If you cannot migrate the code:
{
"success": false,
"code": null,
"reason": "Explanation of why migration failed",
"suggestion": "What the user could do manually",
"confidence": 0
}
## Rules
1. PRESERVE all existing functionality exactly
2. PRESERVE all comments unless they reference removed APIs
3. ADD comments explaining non-obvious changes
4. DO NOT change prop names or component interfaces
5. DO NOT add new dependencies
6. If confidence < 0.7, explain why in warnings
7. Test the code mentally - would it work?
## Context
This code is from an OpenNoodl project. OpenNoodl uses a custom node system where React components are wrapped. The component may reference:
- this.props.noodlNode - Reference to the Noodl node instance
- this.forceUpdate() - Triggers re-render (still valid in React 19)
- this.setStyle() - Noodl method for styling
- this.getRef() - Noodl method for DOM access`;
export const RETRY_PROMPT_TEMPLATE = `The previous migration attempt failed verification.
Previous attempt result:
{previousError}
Previous code:
\`\`\`javascript
{previousCode}
\`\`\`
Please try a different approach. Consider:
1. Maybe the conversion should stay as a class component instead of functional
2. Check if state management is correct
3. Verify event handlers are bound correctly
4. Ensure refs are used correctly
Provide a new migration with the same JSON format.`;
export const HELP_PROMPT_TEMPLATE = `I attempted to migrate this React component {attempts} times but couldn't produce working code.
Original code:
\`\`\`javascript
{originalCode}
\`\`\`
Attempts and errors:
{attemptHistory}
Please analyze this component and provide:
1. Why it's difficult to migrate automatically
2. Step-by-step manual migration instructions
3. Any gotchas or things to watch out for
4. Example code snippets for the tricky parts
Format your response as helpful documentation, not JSON.`;

View File

@@ -0,0 +1,90 @@
/**
* API Key Storage with Encryption
*
* Securely stores Anthropic API keys using Electron's safeStorage API
* for OS-level encryption, with electron-store as a fallback.
*
* @module migration/keyStorage
*/
import Store from 'electron-store';
// safeStorage is available on remote in Electron
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { safeStorage } = require('@electron/remote');
const store = new Store({
name: 'ai-config',
encryptionKey: 'opennoodl-migration' // Additional layer
});
/**
* Save an API key with OS-level encryption
*/
export async function saveApiKey(apiKey: string): Promise<void> {
// Use Electron's safeStorage for OS-level encryption
if (safeStorage.isEncryptionAvailable()) {
const encrypted = safeStorage.encryptString(apiKey);
store.set('anthropic.apiKey', encrypted.toString('base64'));
} else {
// Fallback to electron-store encryption
store.set('anthropic.apiKey', apiKey);
}
}
/**
* Retrieve the stored API key
*/
export async function getApiKey(): Promise<string | null> {
const stored = store.get('anthropic.apiKey') as string | undefined;
if (!stored) return null;
if (safeStorage.isEncryptionAvailable()) {
try {
const buffer = Buffer.from(stored, 'base64');
return safeStorage.decryptString(buffer);
} catch {
return null;
}
}
return stored;
}
/**
* Clear the stored API key
*/
export async function clearApiKey(): Promise<void> {
store.delete('anthropic.apiKey');
}
/**
* Test an Anthropic API key by making a minimal API call
*/
export async function testAnthropicKey(apiKey: string): Promise<boolean> {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Anthropic = require('@anthropic-ai/sdk');
const client = new Anthropic({
apiKey,
dangerouslyAllowBrowser: true // Safe in Electron - code runs locally, not in public browser
});
try {
// Make a minimal API call to verify the key
await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 10,
messages: [{ role: 'user', content: 'Hi' }]
});
return true;
} catch (error: unknown) {
const err = error as { status?: number; message?: string };
if (err.status === 401) {
throw new Error('Invalid API key');
}
if (err.status === 403) {
throw new Error('API key does not have required permissions');
}
throw new Error(`API error: ${err.message || 'Unknown error'}`);
}
}

View File

@@ -0,0 +1,166 @@
.AIConfigPanel {
display: flex;
flex-direction: column;
gap: 24px;
max-width: 600px;
padding: 24px;
}
.Header {
display: flex;
gap: 16px;
align-items: flex-start;
svg {
flex-shrink: 0;
color: var(--theme-color-primary);
}
}
.HeaderText {
display: flex;
flex-direction: column;
gap: 8px;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--theme-color-fg-highlight);
}
}
.Section {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background: var(--theme-color-bg-2);
border-radius: 6px;
h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--theme-color-fg-highlight);
}
a {
color: var(--theme-color-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.InputGroup {
display: flex;
gap: 8px;
align-items: center;
> :first-child {
flex: 1;
}
}
.ValidationSuccess {
display: flex;
gap: 8px;
align-items: center;
color: var(--theme-color-success);
font-size: 13px;
svg {
flex-shrink: 0;
}
}
.ValidationError {
display: flex;
gap: 8px;
align-items: center;
color: var(--theme-color-danger);
font-size: 13px;
svg {
flex-shrink: 0;
}
}
.SecurityNote {
display: flex;
gap: 8px;
align-items: flex-start;
padding: 12px;
background: var(--theme-color-bg-3);
border-radius: 4px;
font-size: 13px;
svg {
flex-shrink: 0;
margin-top: 2px;
color: var(--theme-color-secondary-as-fg);
}
}
.Field {
display: flex;
flex-direction: column;
gap: 8px;
> label:first-child {
font-size: 13px;
font-weight: 500;
color: var(--theme-color-fg-default);
}
}
.BudgetRow {
display: flex;
gap: 8px;
align-items: center;
span {
color: var(--theme-color-fg-default);
}
}
.BudgetInput {
width: 80px;
padding: 6px 10px;
background: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-bg-3);
border-radius: 4px;
color: var(--theme-color-fg-highlight);
font-size: 14px;
&:focus {
outline: none;
border-color: var(--theme-color-primary);
}
}
.Checkbox {
display: flex;
gap: 8px;
align-items: center;
cursor: pointer;
font-size: 13px;
input[type='checkbox'] {
cursor: pointer;
}
span {
color: var(--theme-color-fg-default);
}
}
.Actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 8px;
}

View File

@@ -0,0 +1,229 @@
/**
* AI Configuration Panel
*
* First-time setup UI for configuring Anthropic API key,
* budget controls, and migration preferences.
*/
import React, { useState } from 'react';
import { PrimaryButton, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { TextInput } from '@noodl-core-ui/components/inputs/TextInput';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import { testAnthropicKey } from '../../utils/migration/keyStorage';
import css from './AIConfigPanel.module.scss';
export interface AIConfig {
apiKey: string;
enabled: boolean;
budget: {
maxPerSession: number;
pauseIncrement: number;
showEstimates: boolean;
};
preferences: {
preferFunctional: boolean;
preserveComments: boolean;
verboseOutput: boolean;
};
}
interface AIConfigPanelProps {
existingConfig?: AIConfig;
onSave: (config: AIConfig) => void;
onCancel: () => void;
}
export function AIConfigPanel({ existingConfig, onSave, onCancel }: AIConfigPanelProps) {
const [apiKey, setApiKey] = useState(existingConfig?.apiKey || '');
const [maxBudget, setMaxBudget] = useState(existingConfig?.budget.maxPerSession || 5);
const [pauseIncrement, setPauseIncrement] = useState(existingConfig?.budget.pauseIncrement || 1);
const [showEstimates, setShowEstimates] = useState(existingConfig?.budget.showEstimates ?? true);
const [preferFunctional, setPreferFunctional] = useState(existingConfig?.preferences.preferFunctional ?? true);
const [validating, setValidating] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const [validationSuccess, setValidationSuccess] = useState(false);
const validateApiKey = async () => {
setValidating(true);
setValidationError(null);
setValidationSuccess(false);
try {
await testAnthropicKey(apiKey);
setValidationSuccess(true);
return true;
} catch (error: unknown) {
const err = error as { message?: string };
setValidationError(err.message || 'Validation failed');
return false;
} finally {
setValidating(false);
}
};
const handleSave = async () => {
if (await validateApiKey()) {
onSave({
apiKey,
enabled: true, // Enable AI when config is saved
budget: {
maxPerSession: maxBudget,
pauseIncrement,
showEstimates
},
preferences: {
preferFunctional,
preserveComments: true,
verboseOutput: true
}
});
}
};
return (
<div className={css['AIConfigPanel']}>
<div className={css['Header']}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5z"
fill="currentColor"
opacity="0.2"
/>
<path
d="M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5zm0 2.18l8 3.64v9.18c0 4.52-3.13 8.78-7 9.82-3.87-1.04-7-5.3-7-9.82V7.82l6-2.64z"
fill="currentColor"
/>
<path d="M10.5 13.5l-2-2-1.5 1.5 3.5 3.5 6-6-1.5-1.5-4.5 4.5z" fill="currentColor" />
</svg>
<div className={css['HeaderText']}>
<h3>Configure AI Migration Assistant</h3>
<Text textType={TextType.Shy}>
OpenNoodl uses Claude (by Anthropic) to intelligently migrate complex code patterns.
</Text>
</div>
</div>
{/* API Key Section */}
<section className={css['Section']}>
<h4>Anthropic API Key</h4>
<Text textType={TextType.Shy}>
You&apos;ll need an API key from Anthropic.{' '}
<a href="https://console.anthropic.com" target="_blank" rel="noopener noreferrer">
Get one here
</a>
</Text>
<div className={css['InputGroup']}>
<TextInput
type="password"
placeholder="sk-ant-api03-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
<PrimaryButton
variant={PrimaryButtonVariant.Muted}
label={validating ? 'Validating...' : 'Validate'}
onClick={validateApiKey}
isDisabled={!apiKey || validating}
/>
</div>
{validationSuccess && (
<div className={css['ValidationSuccess']}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
<Text textType={TextType.Shy}>API key validated successfully!</Text>
</div>
)}
{validationError && (
<div className={css['ValidationError']}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
</svg>
<Text textType={TextType.Shy}>{validationError}</Text>
</div>
)}
<div className={css['SecurityNote']}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" />
</svg>
<Text textType={TextType.Shy}>
Your API key is stored locally and encrypted. It&apos;s never sent to OpenNoodl servers - all API calls go
directly to Anthropic.
</Text>
</div>
</section>
{/* Budget Section */}
<section className={css['Section']}>
<h4>Budget Controls</h4>
<div className={css['Field']}>
<label>Maximum spend per migration session</label>
<div className={css['BudgetRow']}>
<span>$</span>
<input
type="number"
min={1}
max={100}
step={1}
value={maxBudget}
onChange={(e) => setMaxBudget(Number(e.target.value))}
className={css['BudgetInput']}
/>
</div>
<Text textType={TextType.Shy}>Typical migration: $0.10 - $2.00</Text>
</div>
<div className={css['Field']}>
<label className={css['Checkbox']}>
<input
type="checkbox"
checked={pauseIncrement > 0}
onChange={(e) => setPauseIncrement(e.target.checked ? 1 : 0)}
/>
<span>Pause and ask before each ${pauseIncrement} increment</span>
</label>
</div>
<div className={css['Field']}>
<label className={css['Checkbox']}>
<input type="checkbox" checked={showEstimates} onChange={(e) => setShowEstimates(e.target.checked)} />
<span>Show cost estimate before each component</span>
</label>
</div>
</section>
{/* Preferences Section */}
<section className={css['Section']}>
<h4>Migration Preferences</h4>
<div className={css['Field']}>
<label className={css['Checkbox']}>
<input type="checkbox" checked={preferFunctional} onChange={(e) => setPreferFunctional(e.target.checked)} />
<span>Prefer converting to functional components with hooks</span>
</label>
<Text textType={TextType.Shy}>
When possible, Claude will convert class components to modern functional components
</Text>
</div>
</section>
<div className={css['Actions']}>
<PrimaryButton variant={PrimaryButtonVariant.Muted} label="Cancel" onClick={onCancel} />
<PrimaryButton
variant={PrimaryButtonVariant.Cta}
label="Save & Continue"
onClick={handleSave}
isDisabled={!apiKey || validating}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
.BudgetApprovalDialog {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
padding: 32px 24px 24px;
max-width: 450px;
}
.Icon {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--theme-color-primary);
color: var(--theme-color-bg-1);
opacity: 0.9;
}
.Content {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
text-align: center;
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--theme-color-fg-highlight);
}
strong {
color: var(--theme-color-fg-highlight);
font-weight: 600;
}
}
.BudgetBar {
margin: 12px 0;
width: 100%;
}
.BudgetBar__Track {
position: relative;
height: 8px;
background: var(--theme-color-bg-3);
border-radius: 4px;
overflow: hidden;
}
.BudgetBar__Spent {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: var(--theme-color-primary);
border-radius: 4px;
transition: width 0.3s ease;
}
.BudgetBar__Pending {
position: absolute;
top: 0;
height: 100%;
background: var(--theme-color-warning);
border-radius: 4px;
opacity: 0.6;
transition: left 0.3s ease, width 0.3s ease;
}
.BudgetLabels {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--theme-color-fg-muted);
.Current {
color: var(--theme-color-primary);
font-weight: 600;
}
}
.Actions {
display: flex;
gap: 12px;
width: 100%;
justify-content: center;
}

View File

@@ -0,0 +1,76 @@
/**
* Budget Approval Dialog
*
* Pause-and-approve dialog shown when reaching spending increments
* during AI-assisted migration.
*/
import React from 'react';
import { PrimaryButton, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import type { BudgetState } from '../../models/migration/BudgetController';
import css from './BudgetApprovalDialog.module.scss';
interface BudgetApprovalDialogProps {
state: BudgetState;
onApprove: () => void;
onDeny: () => void;
}
export function BudgetApprovalDialog({ state, onApprove, onDeny }: BudgetApprovalDialogProps) {
const progressPercent = (state.spent / state.maxPerSession) * 100;
const pendingPercent = (state.pauseIncrement / state.maxPerSession) * 100;
return (
<div className={css['BudgetApprovalDialog']}>
<div className={css['Icon']}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 18v1c0 1.1-.9 2-2 2H5c-1.11 0-2-.9-2-2V5c0-1.1.89-2 2-2h14c1.1 0 2 .9 2 2v1h-9c-1.11 0-2 .9-2 2v8c0 1.1.89 2 2 2h9zm-9-2h10V8H12v8zm4-2.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z" />
</svg>
</div>
<div className={css['Content']}>
<h3>Budget Check</h3>
<Text textType={TextType.Secondary}>
You&apos;ve spent <strong>${state.spent.toFixed(2)}</strong> of your{' '}
<strong>${state.maxPerSession.toFixed(2)}</strong> budget.
</Text>
<Text textType={TextType.Secondary}>
Continue with another <strong>${state.pauseIncrement.toFixed(2)}</strong> allowance?
</Text>
<div className={css['BudgetBar']}>
<div className={css['BudgetBar__Track']}>
<div className={css['BudgetBar__Spent']} style={{ width: `${progressPercent}%` }} />
<div
className={css['BudgetBar__Pending']}
style={{
left: `${progressPercent}%`,
width: `${pendingPercent}%`
}}
/>
</div>
</div>
<div className={css['BudgetLabels']}>
<span>$0</span>
<span className={css['Current']}>${state.spent.toFixed(2)}</span>
<span>${state.maxPerSession.toFixed(2)}</span>
</div>
</div>
<div className={css['Actions']}>
<PrimaryButton variant={PrimaryButtonVariant.Muted} label="Stop Here" onClick={onDeny} />
<PrimaryButton
variant={PrimaryButtonVariant.Cta}
label={`Continue (+$${state.pauseIncrement.toFixed(2)})`}
onClick={onApprove}
/>
</div>
</div>
);
}

View File

@@ -10,23 +10,34 @@
import React, { useCallback, useEffect, useReducer, useState } from 'react';
import { IconName } from '@noodl-core-ui/components/common/Icon';
import { IconButton, IconButtonVariant } from '@noodl-core-ui/components/inputs/IconButton';
import { CoreBaseDialog } from '@noodl-core-ui/components/layout/BaseDialog';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize, TitleVariant } from '@noodl-core-ui/components/typography/Title';
import { IconName } from '@noodl-core-ui/components/common/Icon';
import { MigrationSession, MigrationScan, MigrationResult } from '../../models/migration/types';
import { migrationSessionManager, getStepLabel, getStepNumber, getTotalSteps } from '../../models/migration/MigrationSession';
import {
migrationSessionManager,
getStepLabel,
getStepNumber,
getTotalSteps
} from '../../models/migration/MigrationSession';
import {
MigrationSession,
MigrationScan,
MigrationResult,
AIBudget,
AIPreferences
} from '../../models/migration/types';
import { AIConfigPanel, AIConfig } from './AIConfigPanel';
import { WizardProgress } from './components/WizardProgress';
import { ConfirmStep } from './steps/ConfirmStep';
import { ScanningStep } from './steps/ScanningStep';
import { ReportStep } from './steps/ReportStep';
import { CompleteStep } from './steps/CompleteStep';
import { FailedStep } from './steps/FailedStep';
import css from './MigrationWizard.module.scss';
import { CompleteStep } from './steps/CompleteStep';
import { ConfirmStep } from './steps/ConfirmStep';
import { FailedStep } from './steps/FailedStep';
import { MigratingStep, AiDecision } from './steps/MigratingStep';
import { ReportStep } from './steps/ReportStep';
import { ScanningStep } from './steps/ScanningStep';
// =============================================================================
// Types
@@ -48,8 +59,12 @@ type WizardAction =
| { type: 'SET_TARGET_PATH'; path: string }
| { type: 'START_SCAN' }
| { type: 'SCAN_COMPLETE'; scan: MigrationScan }
| { type: 'CONFIGURE_AI' }
| { type: 'AI_CONFIGURED' }
| { type: 'BACK_TO_REPORT' }
| { type: 'ERROR'; error: Error }
| { type: 'START_MIGRATE'; useAi: boolean }
| { type: 'AI_DECISION'; decision: AiDecision }
| { type: 'MIGRATION_PROGRESS'; progress: number; currentComponent?: string }
| { type: 'COMPLETE'; result: MigrationResult }
| { type: 'RETRY' };
@@ -102,6 +117,31 @@ function wizardReducer(state: WizardState, action: WizardAction): WizardState {
loading: false
};
case 'CONFIGURE_AI':
if (!state.session) return state;
return {
...state,
session: { ...state.session, step: 'configureAi' }
};
case 'AI_CONFIGURED':
if (!state.session) return state;
return {
...state,
session: { ...state.session, step: 'report' }
};
case 'BACK_TO_REPORT':
if (!state.session) return state;
return {
...state,
session: { ...state.session, step: 'report' }
};
case 'AI_DECISION':
// Handle AI decision - just continue migration
return state;
case 'ERROR':
if (!state.session) return state;
return {
@@ -173,12 +213,7 @@ function wizardReducer(state: WizardState, action: WizardAction): WizardState {
// Component
// =============================================================================
export function MigrationWizard({
sourcePath,
projectName,
onComplete,
onCancel
}: MigrationWizardProps) {
export function MigrationWizard({ sourcePath, projectName, onComplete, onCancel }: MigrationWizardProps) {
// Initialize session on mount
const [state, dispatch] = useReducer(wizardReducer, {
session: null,
@@ -197,7 +232,7 @@ export function MigrationWizard({
// Set default target path
const defaultTargetPath = `${sourcePath}-react19`;
migrationSessionManager.setTargetPath(defaultTargetPath);
// Update session with new target path
const updatedSession = migrationSessionManager.getSession();
if (updatedSession) {
@@ -274,6 +309,56 @@ export function MigrationWizard({
}
}, [onComplete]);
const handleConfigureAi = useCallback(async () => {
try {
await migrationSessionManager.transitionTo('configureAi');
dispatch({ type: 'CONFIGURE_AI' });
} catch (error) {
console.error('Failed to transition to AI config:', error);
dispatch({ type: 'ERROR', error: error as Error });
}
}, []);
const handleAiConfigured = useCallback(async (config: AIConfig) => {
try {
// Transform AIConfig to match MigrationSessionManager expectations
const aiConfig = {
...config,
budget: {
...config.budget,
spent: 0 // Initialize spent to 0 for new config
}
};
migrationSessionManager.configureAI(aiConfig);
await migrationSessionManager.transitionTo('report');
dispatch({ type: 'AI_CONFIGURED' });
} catch (error) {
console.error('Failed to configure AI:', error);
dispatch({ type: 'ERROR', error: error as Error });
}
}, []);
const handleBackToReport = useCallback(async () => {
try {
await migrationSessionManager.transitionTo('report');
dispatch({ type: 'BACK_TO_REPORT' });
} catch (error) {
console.error('Failed to go back to report:', error);
dispatch({ type: 'ERROR', error: error as Error });
}
}, []);
const handleAiDecision = useCallback((decision: AiDecision) => {
// For now, just continue - full AI orchestration will be wired in Phase 3
console.log('AI decision:', decision);
dispatch({ type: 'AI_DECISION', decision });
}, []);
const handlePauseMigration = useCallback(() => {
// Pause migration - will be implemented when orchestrator is wired up
console.log('Pause migration requested');
}, []);
// ==========================================================================
// Render
// ==========================================================================
@@ -305,10 +390,23 @@ export function MigrationWizard({
);
case 'scanning':
return <ScanningStep sourcePath={sourcePath} targetPath={session.target.path} />;
case 'configureAi':
return (
<ScanningStep
sourcePath={sourcePath}
targetPath={session.target.path}
<AIConfigPanel
existingConfig={
session.ai
? {
apiKey: session.ai.apiKey || '',
enabled: session.ai.enabled,
budget: session.ai.budget,
preferences: session.ai.preferences
}
: undefined
}
onSave={handleAiConfigured}
onCancel={handleBackToReport}
/>
);
@@ -316,19 +414,22 @@ export function MigrationWizard({
return (
<ReportStep
scan={session.scan!}
onConfigureAi={handleConfigureAi}
onMigrateWithoutAi={() => handleStartMigration(false)}
onMigrateWithAi={() => handleStartMigration(true)}
onCancel={onCancel}
aiEnabled={session.ai?.enabled || false}
/>
);
case 'migrating':
return (
<ScanningStep
sourcePath={sourcePath}
targetPath={session.target.path}
isMigrating
progress={session.progress}
<MigratingStep
progress={session.progress || { phase: 'copying', current: 0, total: 0, log: [] }}
useAi={!!session.ai}
budget={session.ai?.budget}
onAiDecision={handleAiDecision}
onPause={handlePauseMigration}
/>
);
@@ -343,13 +444,7 @@ export function MigrationWizard({
);
case 'failed':
return (
<FailedStep
error={state.error}
onRetry={handleRetry}
onCancel={onCancel}
/>
);
return <FailedStep error={state.error} onRetry={handleRetry} onCancel={onCancel} />;
default:
return null;
@@ -361,11 +456,7 @@ export function MigrationWizard({
<div className={css['WizardContainer']}>
{/* Close Button */}
<div className={css['CloseButton']}>
<IconButton
icon={IconName.Close}
onClick={onCancel}
variant={IconButtonVariant.Transparent}
/>
<IconButton icon={IconName.Close} onClick={onCancel} variant={IconButtonVariant.Transparent} />
</div>
{/* Header */}
@@ -383,9 +474,7 @@ export function MigrationWizard({
totalSteps={totalSteps}
stepLabels={['Confirm', 'Scan', 'Report', 'Migrate', 'Complete']}
/>
<div className={css['StepContainer']}>
{renderStep()}
</div>
<div className={css['StepContainer']}>{renderStep()}</div>
</div>
</div>
</CoreBaseDialog>

View File

@@ -0,0 +1,241 @@
/**
* MigratingStep Styles
*
* AI-assisted migration progress display with budget tracking and decision panels.
*/
.Root {
display: flex;
flex-direction: column;
height: 100%;
gap: 16px;
}
.Header {
display: flex;
align-items: center;
gap: 12px;
}
/* Budget Section */
.BudgetSection {
padding: 12px 16px;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
border: 1px solid var(--theme-color-bg-2);
}
.BudgetHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.BudgetBar {
height: 6px;
background-color: var(--theme-color-bg-2);
border-radius: 3px;
overflow: hidden;
}
.BudgetFill {
height: 100%;
background-color: var(--theme-color-primary);
border-radius: 3px;
transition: width 0.3s ease, background-color 0.3s ease;
&.is-warning {
background-color: var(--theme-color-warning);
}
}
/* Progress Section */
.ProgressSection {
display: flex;
flex-direction: column;
gap: 8px;
}
.ProgressBar {
height: 8px;
background-color: var(--theme-color-bg-3);
border-radius: 4px;
overflow: hidden;
}
.ProgressFill {
height: 100%;
background-color: var(--theme-color-primary);
border-radius: 4px;
transition: width 0.3s ease;
}
/* Current Component */
.CurrentComponent {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
border: 1px solid var(--theme-color-primary);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
border-color: var(--theme-color-primary);
background-color: var(--theme-color-bg-3);
}
50% {
border-color: rgba(59, 130, 246, 0.5);
background-color: rgba(59, 130, 246, 0.05);
}
}
/* Log Section */
.LogSection {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 200px;
overflow-y: auto;
}
.LogEntries {
display: flex;
flex-direction: column;
gap: 4px;
}
.LogEntry {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 12px;
background-color: var(--theme-color-bg-3);
border-radius: 6px;
font-size: 13px;
animation: slideIn 0.2s ease;
svg {
margin-top: 2px;
flex-shrink: 0;
}
&.is-info {
border-left: 3px solid var(--theme-color-secondary-as-fg);
svg {
color: var(--theme-color-secondary-as-fg);
}
}
&.is-success {
border-left: 3px solid var(--theme-color-success);
svg {
color: var(--theme-color-success);
}
}
&.is-warning {
border-left: 3px solid var(--theme-color-warning);
svg {
color: var(--theme-color-warning);
}
}
&.is-error {
border-left: 3px solid var(--theme-color-danger);
svg {
color: var(--theme-color-danger);
}
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-8px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.LogContent {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
/* AI Decision Panel */
.DecisionPanel {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background-color: var(--theme-color-bg-3);
border: 2px solid var(--theme-color-warning);
border-radius: 8px;
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.DecisionHeader {
display: flex;
align-items: center;
gap: 12px;
svg {
color: var(--theme-color-warning);
flex-shrink: 0;
}
}
.AttemptHistory {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
background-color: var(--theme-color-bg-2);
border-radius: 6px;
}
.AttemptEntry {
display: flex;
gap: 4px;
font-size: 13px;
}
.DecisionOptions {
display: flex;
gap: 8px;
margin-top: 8px;
}
/* Actions */
.Actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
border-top: 1px solid var(--theme-color-bg-2);
}

View File

@@ -0,0 +1,279 @@
/**
* MigratingStep
*
* Step 4 of the migration wizard: Shows real-time migration progress with AI.
* Displays budget tracking, component progress, and AI decision panels.
*
* @module noodl-editor/views/migration/steps
* @since 1.2.0
*/
import React from 'react';
import { ActivityIndicator } from '@noodl-core-ui/components/common/ActivityIndicator';
import { PrimaryButton, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
import { MigrationProgress, AIBudget } from '../../../models/migration/types';
import css from './MigratingStep.module.scss';
export interface AiDecisionRequest {
componentId: string;
componentName: string;
attempts: number;
attemptHistory: Array<{ description: string }>;
costSpent: number;
retryCost: number;
}
export interface AiDecision {
componentId: string;
action: 'retry' | 'skip' | 'getHelp';
}
export interface MigratingStepProps {
/** Progress information */
progress: MigrationProgress;
/** Whether AI is being used */
useAi: boolean;
/** AI budget info (if using AI) */
budget?: AIBudget;
/** AI decision request (if awaiting user decision) */
awaitingDecision?: AiDecisionRequest | null;
/** Called when user makes an AI decision */
onAiDecision?: (decision: AiDecision) => void;
/** Called when user pauses migration */
onPause?: () => void;
}
export function MigratingStep({
progress,
useAi,
budget,
awaitingDecision,
onAiDecision,
onPause
}: MigratingStepProps) {
const progressPercent = Math.round((progress.current / progress.total) * 100);
const budgetPercent = budget ? (budget.spent / budget.maxPerSession) * 100 : 0;
return (
<div className={css['Root']}>
<VStack hasSpacing>
<div className={css['Header']}>
<ActivityIndicator />
<Title size={TitleSize.Medium}>{useAi ? 'AI Migration in Progress' : 'Migrating Project...'}</Title>
</div>
<Text textType={TextType.Secondary}>Phase: {getPhaseLabel(progress.phase)}</Text>
{/* Budget Display (if using AI) */}
{useAi && budget && (
<div className={css['BudgetSection']}>
<div className={css['BudgetHeader']}>
<Text size={TextSize.Small} textType={TextType.Proud}>
AI Budget
</Text>
<Text size={TextSize.Small}>
${budget.spent.toFixed(2)} / ${budget.maxPerSession.toFixed(2)}
</Text>
</div>
<div className={css['BudgetBar']}>
<div
className={`${css['BudgetFill']} ${budgetPercent > 80 ? css['is-warning'] : ''}`}
style={{ width: `${Math.min(budgetPercent, 100)}%` }}
/>
</div>
</div>
)}
{/* Overall Progress */}
<div className={css['ProgressSection']}>
<div className={css['ProgressBar']}>
<div className={css['ProgressFill']} style={{ width: `${progressPercent}%` }} />
</div>
<Text size={TextSize.Small} textType={TextType.Shy}>
{progress.current} / {progress.total} components
</Text>
</div>
{/* Current Component */}
{progress.currentComponent && !awaitingDecision && (
<div className={css['CurrentComponent']}>
<ActivityIndicator />
<Text size={TextSize.Small}>{progress.currentComponent}</Text>
</div>
)}
{/* Activity Log */}
{progress.log && progress.log.length > 0 && (
<div className={css['LogSection']}>
<div className={css['LogEntries']}>
{progress.log.slice(-6).map((entry, index) => (
<div key={index} className={`${css['LogEntry']} ${css[`is-${entry.level}`]}`}>
<LogIcon level={entry.level} />
<div className={css['LogContent']}>
{entry.component && (
<Text size={TextSize.Small} textType={TextType.Proud} isSpan>
{entry.component}:{' '}
</Text>
)}
<Text size={TextSize.Small} isSpan>
{entry.message}
</Text>
</div>
{entry.cost !== undefined && (
<Text size={TextSize.Small} textType={TextType.Shy}>
${entry.cost.toFixed(2)}
</Text>
)}
</div>
))}
</div>
</div>
)}
{/* AI Decision Panel */}
{awaitingDecision && onAiDecision && <AiDecisionPanel request={awaitingDecision} onDecision={onAiDecision} />}
</VStack>
{/* Actions */}
<div className={css['Actions']}>
<PrimaryButton
label="Pause Migration"
variant={PrimaryButtonVariant.Muted}
onClick={onPause}
isDisabled={!!awaitingDecision}
/>
</div>
</div>
);
}
// =============================================================================
// Sub-Components
// =============================================================================
interface AiDecisionPanelProps {
request: AiDecisionRequest;
onDecision: (decision: AiDecision) => void;
}
function AiDecisionPanel({ request, onDecision }: AiDecisionPanelProps) {
return (
<div className={css['DecisionPanel']}>
<div className={css['DecisionHeader']}>
<ToolIcon />
<Title size={TitleSize.Small}>{request.componentName} - Needs Your Input</Title>
</div>
<Text size={TextSize.Small}>
Claude attempted {request.attempts} migrations but the component still has issues. Here&apos;s what happened:
</Text>
<div className={css['AttemptHistory']}>
{request.attemptHistory.map((attempt, i) => (
<div key={i} className={css['AttemptEntry']}>
<Text size={TextSize.Small} textType={TextType.Proud} isSpan>
Attempt {i + 1}:
</Text>{' '}
<Text size={TextSize.Small} isSpan>
{attempt.description}
</Text>
</div>
))}
</div>
<Text size={TextSize.Small} textType={TextType.Shy}>
Cost so far: ${request.costSpent.toFixed(2)}
</Text>
<div className={css['DecisionOptions']}>
<HStack hasSpacing>
<PrimaryButton
label={`Try Again (~$${request.retryCost.toFixed(2)})`}
onClick={() => onDecision({ componentId: request.componentId, action: 'retry' })}
/>
<PrimaryButton
label="Skip Component"
variant={PrimaryButtonVariant.Muted}
onClick={() => onDecision({ componentId: request.componentId, action: 'skip' })}
/>
<PrimaryButton
label="Get Suggestions (~$0.02)"
variant={PrimaryButtonVariant.Muted}
onClick={() => onDecision({ componentId: request.componentId, action: 'getHelp' })}
/>
</HStack>
</div>
</div>
);
}
// =============================================================================
// Helper Functions & Icons
// =============================================================================
function getPhaseLabel(phase?: string): string {
const labels: Record<string, string> = {
copying: 'Copying files',
automatic: 'Applying automatic fixes',
'ai-assisted': 'AI-assisted migration',
finalizing: 'Finalizing'
};
return labels[phase || ''] || 'Starting';
}
function LogIcon({ level }: { level: string }) {
const icons: Record<string, JSX.Element> = {
info: (
<svg viewBox="0 0 16 16" width={12} height={12}>
<path
d="M8 16A8 8 0 108 0a8 8 0 000 16zm.93-9.412l-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287h.001zm-.043-3.33a.86.86 0 110 1.72.86.86 0 010-1.72z"
fill="currentColor"
/>
</svg>
),
success: (
<svg viewBox="0 0 16 16" width={12} height={12}>
<path
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
fill="currentColor"
/>
</svg>
),
warning: (
<svg viewBox="0 0 16 16" width={12} height={12}>
<path
d="M8.863 1.035c-.39-.678-1.336-.678-1.726 0L.187 12.78c-.403.7.096 1.57.863 1.57h13.9c.767 0 1.266-.87.863-1.57L8.863 1.035zM8 5a.75.75 0 01.75.75v2.5a.75.75 0 11-1.5 0v-2.5A.75.75 0 018 5zm0 7a1 1 0 100-2 1 1 0 000 2z"
fill="currentColor"
/>
</svg>
),
error: (
<svg viewBox="0 0 16 16" width={12} height={12}>
<path
d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"
fill="currentColor"
/>
</svg>
)
};
return icons[level] || icons.info;
}
function ToolIcon() {
return (
<svg viewBox="0 0 16 16" width={20} height={20}>
<path
d="M5.433 2.304A4.492 4.492 0 003.5 6c0 1.598.832 3.002 2.09 3.802.518.328.929.923.902 1.64l-.086 2.27a.75.75 0 01-.75.72h-1.3a.75.75 0 01-.75-.72l-.086-2.27c-.027-.717.384-1.312.902-1.64A4.495 4.495 0 003.5 6a5.99 5.99 0 012.433-4.864.75.75 0 011.134.64v3.046l.5.865.5-.865V1.776a.75.75 0 011.134-.64A5.99 5.99 0 0111.5 6a4.495 4.495 0 01-.922 3.802c-.518.328-.929.923-.902 1.64l.086 2.27a.75.75 0 01-.75.72h-1.3a.75.75 0 01-.75-.72l-.086-2.27c-.027-.717.384-1.312.902-1.64A4.495 4.495 0 007.5 6c0-.54-.185-1.061-.433-1.548"
fill="currentColor"
/>
</svg>
);
}
export default MigratingStep;

View File

@@ -16,25 +16,30 @@ import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/T
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
import { MigrationScan, ComponentMigrationInfo } from '../../../models/migration/types';
import css from './ReportStep.module.scss';
export interface ReportStepProps {
/** Scan results */
scan: MigrationScan;
/** Called when user chooses to configure AI */
onConfigureAi: () => void;
/** Called when user chooses to migrate without AI */
onMigrateWithoutAi: () => void;
/** Called when user chooses to migrate with AI */
onMigrateWithAi: () => void;
/** Called when user cancels */
onCancel: () => void;
/** Whether AI is configured and enabled */
aiEnabled?: boolean;
}
export function ReportStep({
scan,
onConfigureAi,
onMigrateWithoutAi,
onMigrateWithAi,
onCancel
onCancel,
aiEnabled = false
}: ReportStepProps) {
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
@@ -56,24 +61,9 @@ export function ReportStep({
{/* Summary Stats */}
<div className={css['SummaryStats']}>
<StatCard
icon={<CheckCircleIcon />}
value={automatic.length}
label="Automatic"
variant="success"
/>
<StatCard
icon={<ZapIcon />}
value={simpleFixes.length}
label="Simple Fixes"
variant="info"
/>
<StatCard
icon={<ToolIcon />}
value={needsReview.length}
label="Needs Review"
variant="warning"
/>
<StatCard icon={<CheckCircleIcon />} value={automatic.length} label="Automatic" variant="success" />
<StatCard icon={<ZapIcon />} value={simpleFixes.length} label="Simple Fixes" variant="info" />
<StatCard icon={<ToolIcon />} value={needsReview.length} label="Needs Review" variant="warning" />
</div>
{/* Category Sections */}
@@ -86,9 +76,7 @@ export function ReportStep({
items={automatic}
variant="success"
expanded={expandedCategory === 'automatic'}
onToggle={() =>
setExpandedCategory(expandedCategory === 'automatic' ? null : 'automatic')
}
onToggle={() => setExpandedCategory(expandedCategory === 'automatic' ? null : 'automatic')}
/>
{/* Simple Fixes */}
@@ -100,9 +88,7 @@ export function ReportStep({
items={simpleFixes}
variant="info"
expanded={expandedCategory === 'simpleFixes'}
onToggle={() =>
setExpandedCategory(expandedCategory === 'simpleFixes' ? null : 'simpleFixes')
}
onToggle={() => setExpandedCategory(expandedCategory === 'simpleFixes' ? null : 'simpleFixes')}
showIssueDetails
/>
)}
@@ -116,9 +102,7 @@ export function ReportStep({
items={needsReview}
variant="warning"
expanded={expandedCategory === 'needsReview'}
onToggle={() =>
setExpandedCategory(expandedCategory === 'needsReview' ? null : 'needsReview')
}
onToggle={() => setExpandedCategory(expandedCategory === 'needsReview' ? null : 'needsReview')}
showIssueDetails
/>
)}
@@ -131,12 +115,15 @@ export function ReportStep({
<RobotIcon />
</div>
<div className={css['AiPromptContent']}>
<Title size={TitleSize.Small}>AI-Assisted Migration (Coming Soon)</Title>
<Title size={TitleSize.Small}>AI-Assisted Migration Available</Title>
<Text textType={TextType.Secondary} size={TextSize.Small}>
Claude can help automatically fix the {totalIssues} components that need
code changes. Estimated cost: ~${estimatedCost.toFixed(2)}
Claude can help automatically fix the {totalIssues} components that need code changes. Estimated cost:
~${estimatedCost.toFixed(2)}
</Text>
</div>
{!aiEnabled && (
<PrimaryButton label="Configure AI" variant={PrimaryButtonVariant.Muted} onClick={onConfigureAi} />
)}
</div>
)}
</VStack>
@@ -144,22 +131,12 @@ export function ReportStep({
{/* Actions */}
<div className={css['Actions']}>
<HStack hasSpacing>
<PrimaryButton
label="Cancel"
variant={PrimaryButtonVariant.Muted}
onClick={onCancel}
/>
<PrimaryButton label="Cancel" variant={PrimaryButtonVariant.Muted} onClick={onCancel} />
<PrimaryButton
label={allAutomatic ? 'Migrate Project' : 'Migrate (Auto Only)'}
onClick={onMigrateWithoutAi}
/>
{!allAutomatic && (
<PrimaryButton
label="Migrate with AI"
onClick={onMigrateWithAi}
isDisabled // AI not yet implemented
/>
)}
{!allAutomatic && aiEnabled && <PrimaryButton label="Migrate with AI" onClick={onMigrateWithAi} />}
</HStack>
</div>
</div>
@@ -240,7 +217,8 @@ function CategorySection({
<li key={issue.id}>
<code>{issue.type}</code>
<Text size={TextSize.Small} isSpan textType={TextType.Secondary}>
{' '}{issue.description}
{' '}
{issue.description}
</Text>
</li>
))}