mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 14:52:55 +01:00
React 19 runtime migration complete, AI-assisted migration underway
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.`;
|
||||
@@ -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'}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'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;
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user