mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 23:02:56 +01:00
React 19 runtime migration complete, AI-assisted migration underway
This commit is contained in:
@@ -2,11 +2,207 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Session 11: MigrationWizard AI Integration Complete
|
||||
|
||||
#### 2024-12-20
|
||||
|
||||
**Completed:**
|
||||
|
||||
- **MigrationWizard.tsx** - Full AI integration with proper wiring:
|
||||
- Added imports for MigratingStep, AiDecision, AIConfigPanel, AIConfig types
|
||||
- Added action types: CONFIGURE_AI, AI_CONFIGURED, BACK_TO_REPORT, AI_DECISION
|
||||
- Added reducer cases for all AI flow transitions
|
||||
- Implemented handlers:
|
||||
- `handleConfigureAi()` - Navigate to AI configuration screen
|
||||
- `handleAiConfigured()` - Save AI config and return to report (transforms config with spent: 0)
|
||||
- `handleBackToReport()` - Cancel AI config and return to report
|
||||
- `handleAiDecision()` - Handle user decisions during AI migration
|
||||
- `handlePauseMigration()` - Pause ongoing migration
|
||||
- Added render cases:
|
||||
- `configureAi` step - Renders AIConfigPanel with save/cancel callbacks
|
||||
- Updated `report` step - Added onConfigureAi prop and aiEnabled flag
|
||||
- Updated `migrating` step - Replaced ScanningStep with MigratingStep, includes AI decision handling
|
||||
|
||||
**Technical Details:**
|
||||
|
||||
- AIConfig transformation adds `spent: 0` to budget before passing to MigrationSessionManager
|
||||
- AI configuration flow: Report → Configure AI → Report (with AI enabled) → Migrate
|
||||
- MigratingStep receives progress, useAi flag, budget, and decision/pause callbacks
|
||||
- All unused imports removed (AIBudget, AIPreferences were for type reference only)
|
||||
- Handlers use console.log for Phase 3 orchestrator hookup points
|
||||
|
||||
**Integration Status:**
|
||||
|
||||
✅ UI components complete (MigratingStep, ReportStep, AIConfigPanel)
|
||||
✅ State management wired (reducer actions, handlers)
|
||||
✅ Render flow complete (all step cases implemented)
|
||||
⏳ Backend orchestration (Phase 3 - AIMigrationOrchestrator integration)
|
||||
|
||||
**Files Modified:**
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/views/migration/
|
||||
└── MigrationWizard.tsx (Complete AI integration)
|
||||
```
|
||||
|
||||
**Next Steps:**
|
||||
|
||||
- Phase 3: Wire AIMigrationOrchestrator into MigrationSession.startMigration()
|
||||
- Add event listeners for budget approval dialogs
|
||||
- Handle real-time migration progress updates
|
||||
- End-to-end testing with actual Claude API
|
||||
|
||||
---
|
||||
|
||||
### Session 10: AI Integration into Wizard
|
||||
|
||||
#### 2024-12-20
|
||||
|
||||
**Added:**
|
||||
|
||||
- **MigratingStep Component** - Real-time AI migration progress display:
|
||||
- `MigratingStep.tsx` - Component with budget tracking, progress display, and AI decision panels
|
||||
- `MigratingStep.module.scss` - Styling with animations for budget warnings and component progress
|
||||
- Features:
|
||||
- Budget display with warning state when >80% spent
|
||||
- Component-by-component progress tracking
|
||||
- Activity log with color-coded entries (info/success/warning/error)
|
||||
- AI decision panel for handling migration failures
|
||||
- Pause migration functionality
|
||||
|
||||
**Updated:**
|
||||
|
||||
- **ReportStep.tsx** - Enabled AI configuration:
|
||||
- Added `onConfigureAi` callback prop
|
||||
- Added `aiEnabled` prop to track AI configuration state
|
||||
- "Configure AI" button appears when issues exist and AI not yet configured
|
||||
- "Migrate with AI" button enabled when AI is configured
|
||||
- Updated AI prompt text from "Coming Soon" to "Available"
|
||||
|
||||
**Technical Implementation:**
|
||||
|
||||
- MigratingStep handles both AI and non-AI migration display
|
||||
- Decision panel allows user choices: retry, skip, or get help
|
||||
- Budget bar changes color (orange) when approaching limit
|
||||
- Real-time log entries with sliding animations
|
||||
- Integrates with existing AIBudget and MigrationProgress types
|
||||
|
||||
**Files Created:**
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/views/migration/steps/
|
||||
├── MigratingStep.tsx
|
||||
└── MigratingStep.module.scss
|
||||
```
|
||||
|
||||
**Files Modified:**
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/views/migration/steps/
|
||||
└── ReportStep.tsx (AI configuration support)
|
||||
```
|
||||
|
||||
**Next Steps:**
|
||||
|
||||
- Wire MigratingStep into MigrationWizard.tsx
|
||||
- Connect AI configuration flow (configureAi step)
|
||||
- Handle migrating step with AI decision logic
|
||||
- End-to-end testing
|
||||
|
||||
---
|
||||
|
||||
### Session 9: AI Migration Implementation
|
||||
|
||||
#### 2024-12-20
|
||||
|
||||
**Added:**
|
||||
|
||||
- **Complete AI-Assisted Migration System** - Full implementation of Session 4 from spec:
|
||||
- Core AI infrastructure (5 files):
|
||||
- `claudePrompts.ts` - System prompts and templates for guiding Claude migrations
|
||||
- `keyStorage.ts` - Encrypted API key storage using Electron's safeStorage API
|
||||
- `claudeClient.ts` - Anthropic API wrapper with cost tracking and response parsing
|
||||
- `BudgetController.ts` - Spending limits and approval flow management
|
||||
- `AIMigrationOrchestrator.ts` - Coordinates multi-component migrations with retry logic
|
||||
- UI components (4 files):
|
||||
- `AIConfigPanel.tsx` + `.module.scss` - First-time setup for API key, budget, and preferences
|
||||
- `BudgetApprovalDialog.tsx` + `.module.scss` - Pause dialog for budget approval
|
||||
|
||||
**Technical Implementation:**
|
||||
|
||||
- **Claude Integration:**
|
||||
- Model: `claude-sonnet-4-20250514`
|
||||
- Pricing: $3/$15 per 1M tokens (input/output)
|
||||
- Max tokens: 4096 for migrations, 2048 for help requests
|
||||
- Response format: Structured JSON with success/changes/warnings/confidence
|
||||
- **Budget Controls:**
|
||||
|
||||
- Default: $5 max per session, $1 pause increments
|
||||
- Hard limits prevent budget overruns
|
||||
- Real-time cost tracking and display
|
||||
- User approval required at spending increments
|
||||
|
||||
- **Migration Flow:**
|
||||
|
||||
1. User configures API key + budget (one-time setup)
|
||||
2. Wizard scans project → identifies components needing AI help
|
||||
3. User reviews and approves estimated cost
|
||||
4. AI migrates each component with up to 3 retry attempts
|
||||
5. Babel syntax verification after each migration
|
||||
6. Failed migrations get manual suggestions via "Get Help" option
|
||||
|
||||
- **Security:**
|
||||
|
||||
- API keys stored with OS-level encryption (safeStorage)
|
||||
- Fallback to electron-store encryption
|
||||
- Keys never sent to OpenNoodl servers
|
||||
- All API calls go directly to Anthropic
|
||||
|
||||
- **Verification:**
|
||||
- Babel parser checks syntax validity
|
||||
- Forbidden pattern detection (componentWillMount, string refs, etc.)
|
||||
- Confidence threshold enforcement (default: 0.7)
|
||||
- User decision points for low-confidence migrations
|
||||
|
||||
**Files Created:**
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/utils/migration/
|
||||
├── claudePrompts.ts
|
||||
├── keyStorage.ts
|
||||
└── claudeClient.ts
|
||||
|
||||
packages/noodl-editor/src/editor/src/models/migration/
|
||||
├── BudgetController.ts
|
||||
└── AIMigrationOrchestrator.ts
|
||||
|
||||
packages/noodl-editor/src/editor/src/views/migration/
|
||||
├── AIConfigPanel.tsx
|
||||
├── AIConfigPanel.module.scss
|
||||
├── BudgetApprovalDialog.tsx
|
||||
└── BudgetApprovalDialog.module.scss
|
||||
```
|
||||
|
||||
**Dependencies Added:**
|
||||
|
||||
- `@anthropic-ai/sdk` - Claude API client
|
||||
- `@babel/parser` - Code syntax verification
|
||||
|
||||
**Next Steps:**
|
||||
|
||||
- Integration into MigrationSession.ts (orchestrate AI phase)
|
||||
- Update ReportStep.tsx to enable AI configuration
|
||||
- Add MigratingStep.tsx for real-time AI progress display
|
||||
- Testing with real project migrations
|
||||
|
||||
---
|
||||
|
||||
### Session 8: Migration Marker Fix
|
||||
|
||||
#### 2024-12-15
|
||||
|
||||
**Fixed:**
|
||||
|
||||
- **Migrated Projects Still Showing as Legacy** (`MigrationSession.ts`):
|
||||
- Root cause: `executeFinalizePhase()` was a placeholder with just `await this.simulateDelay(200)` and never updated project.json
|
||||
- The runtime detection system checks for `runtimeVersion` or `migratedFrom` fields in project.json
|
||||
@@ -23,6 +219,7 @@
|
||||
- Migrated projects now correctly identified as React 19 in project list
|
||||
|
||||
**Technical Notes:**
|
||||
|
||||
- Runtime detection checks these fields in order:
|
||||
1. `runtimeVersion` field (highest confidence)
|
||||
2. `migratedFrom` field (indicates already migrated)
|
||||
@@ -32,6 +229,7 @@
|
||||
- Adding `runtimeVersion: "react19"` provides "high" confidence detection
|
||||
|
||||
**Files Modified:**
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts
|
||||
```
|
||||
@@ -43,7 +241,9 @@ packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts
|
||||
#### 2024-12-14
|
||||
|
||||
**Fixed:**
|
||||
|
||||
- **Text Color Invisible (Gray on Gray)** (All migration SCSS files):
|
||||
|
||||
- Root cause: SCSS files used non-existent CSS variables like `--theme-color-fg-1` and `--theme-color-secondary` for text
|
||||
- `--theme-color-fg-1` doesn't exist in the theme - it's `--theme-color-fg-highlight`
|
||||
- `--theme-color-secondary` is a dark teal color (`#005769`) meant for backgrounds, not text
|
||||
@@ -54,6 +254,7 @@ packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts
|
||||
- Text is now visible with proper contrast against dark backgrounds
|
||||
|
||||
- **Migration Does Not Create Project Folder** (`MigrationSession.ts`):
|
||||
|
||||
- Root cause: `executeCopyPhase()` was a placeholder that never actually copied files
|
||||
- Implemented actual file copying using `@noodl/platform` filesystem API
|
||||
- New `copyDirectoryRecursive()` method recursively copies all project files
|
||||
@@ -67,6 +268,7 @@ packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts
|
||||
- Shows success toast and updates project list
|
||||
|
||||
**Technical Notes:**
|
||||
|
||||
- Theme color variable naming conventions:
|
||||
- `--theme-color-bg-*` for backgrounds (bg-1 through bg-4, darker to lighter)
|
||||
- `--theme-color-fg-*` for foreground/text (fg-highlight, fg-default, fg-default-shy, fg-muted)
|
||||
@@ -80,6 +282,7 @@ packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts
|
||||
- `filesystem.writeFile(path, content)` - write file contents
|
||||
|
||||
**Files Modified:**
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts
|
||||
packages/noodl-editor/src/editor/src/views/projectsview.ts
|
||||
@@ -98,7 +301,9 @@ packages/noodl-editor/src/editor/src/views/migration/components/WizardProgress.m
|
||||
#### 2024-12-14
|
||||
|
||||
**Fixed:**
|
||||
|
||||
- **"Start Migration" Button Does Nothing** (`MigrationWizard.tsx`):
|
||||
|
||||
- Root cause: useReducer `state.session` was never initialized
|
||||
- Component used two sources of truth:
|
||||
1. `migrationSessionManager.getSession()` for rendering - worked fine
|
||||
@@ -108,6 +313,7 @@ packages/noodl-editor/src/editor/src/views/migration/components/WizardProgress.m
|
||||
- Button clicks now properly dispatch actions and update state
|
||||
|
||||
- **Switched from Modal to CoreBaseDialog** (`MigrationWizard.tsx`):
|
||||
|
||||
- Modal component was causing layout and interaction issues
|
||||
- CoreBaseDialog is the pattern used by working dialogs like ConfirmDialog
|
||||
- Changed import and component usage to use CoreBaseDialog directly
|
||||
@@ -118,9 +324,11 @@ packages/noodl-editor/src/editor/src/views/migration/components/WizardProgress.m
|
||||
- Renamed one to `currentSession` to avoid redeclaration error
|
||||
|
||||
**Technical Notes:**
|
||||
|
||||
- When using both an external manager AND useReducer, reducer state must be explicitly synchronized
|
||||
- CoreBaseDialog is the preferred pattern for dialogs - simpler and more reliable than Modal
|
||||
- Pattern for initializing reducer with async data:
|
||||
|
||||
```tsx
|
||||
// In useEffect after async operation:
|
||||
dispatch({ type: 'SET_SESSION', session: createdSession });
|
||||
@@ -131,6 +339,7 @@ packages/noodl-editor/src/editor/src/views/migration/components/WizardProgress.m
|
||||
```
|
||||
|
||||
**Files Modified:**
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.tsx
|
||||
```
|
||||
@@ -142,13 +351,16 @@ packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.tsx
|
||||
#### 2024-12-14
|
||||
|
||||
**Fixed:**
|
||||
|
||||
- **Migration Wizard Buttons Not Clickable** (`BaseDialog.module.scss`):
|
||||
|
||||
- Root cause: The `::after` pseudo-element on `.VisibleDialog` was covering the entire dialog
|
||||
- This overlay had no `pointer-events: none`, blocking all click events
|
||||
- Added `pointer-events: none` to `::after` pseudo-element
|
||||
- All buttons, icons, and interactive elements now work correctly
|
||||
|
||||
- **Migration Wizard Not Scrollable** (`MigrationWizard.module.scss`):
|
||||
|
||||
- Root cause: Missing proper flex layout and overflow settings
|
||||
- Added `display: flex`, `flex-direction: column`, and `overflow: hidden` to `.MigrationWizard`
|
||||
- Added `flex: 1`, `min-height: 0`, and `overflow-y: auto` to `.WizardContent`
|
||||
@@ -165,6 +377,7 @@ packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.tsx
|
||||
- Text now has proper contrast against modal background
|
||||
|
||||
**Technical Notes:**
|
||||
|
||||
- BaseDialog uses a `::after` pseudo-element for background color rendering
|
||||
- Without `pointer-events: none`, this pseudo covers content and blocks interaction
|
||||
- Theme color variables follow pattern: `--theme-color-{semantic-name}`
|
||||
@@ -172,6 +385,7 @@ packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.tsx
|
||||
- Flex containers need `min-height: 0` on children to allow proper shrinking/scrolling
|
||||
|
||||
**Files Modified:**
|
||||
|
||||
```
|
||||
packages/noodl-core-ui/src/components/layout/BaseDialog/BaseDialog.module.scss
|
||||
packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.module.scss
|
||||
@@ -190,19 +404,23 @@ packages/noodl-editor/src/editor/src/views/migration/steps/FailedStep.module.scs
|
||||
#### 2024-12-14
|
||||
|
||||
**Fixed:**
|
||||
|
||||
- **EPIPE Error on Project Open** (`cloud-function-server.js`):
|
||||
|
||||
- Added `safeLog()` wrapper function that catches and ignores EPIPE errors
|
||||
- EPIPE occurs when stdout pipe is broken (e.g., terminal closed)
|
||||
- All console.log calls in cloud-function-server now use safeLog
|
||||
- Prevents editor crash when output pipe becomes unavailable
|
||||
|
||||
- **Runtime Detection Defaulting** (`ProjectScanner.ts`):
|
||||
|
||||
- Changed fallback runtime version from `'unknown'` to `'react17'`
|
||||
- Projects without explicit markers now correctly identified as legacy
|
||||
- Ensures old Noodl projects trigger migration UI even without version flags
|
||||
- Updated indicator message: "No React 19 markers found - assuming legacy React 17 project"
|
||||
|
||||
- **Migration UI Not Showing** (`projectsview.ts`):
|
||||
|
||||
- Added listener for `'runtimeDetectionComplete'` event
|
||||
- Project list now re-renders after async runtime detection completes
|
||||
- Legacy badges and migrate buttons appear correctly for React 17 projects
|
||||
@@ -213,12 +431,14 @@ packages/noodl-editor/src/editor/src/views/migration/steps/FailedStep.module.scs
|
||||
- Webpack cache required clearing after fix
|
||||
|
||||
**Technical Notes:**
|
||||
|
||||
- safeLog pattern: `try { console.log(...args); } catch (e) { /* ignore EPIPE */ }`
|
||||
- Runtime detection is async - UI must re-render after detection completes
|
||||
- Webpack caches SCSS files aggressively - cache clearing may be needed after SCSS fixes
|
||||
- The `runtimeDetectionComplete` event fires after `detectAllProjectRuntimes()` completes
|
||||
|
||||
**Files Modified:**
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/main/src/cloud-function-server.js
|
||||
packages/noodl-editor/src/editor/src/models/migration/ProjectScanner.ts
|
||||
@@ -233,13 +453,16 @@ packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.module.scss
|
||||
#### 2024-12-14
|
||||
|
||||
**Added:**
|
||||
|
||||
- Extended `DialogLayerModel.tsx` with generic `showDialog()` method:
|
||||
|
||||
- Accepts render function `(close: () => void) => JSX.Element`
|
||||
- Options include `onClose` callback for cleanup
|
||||
- Enables mounting custom React components (like MigrationWizard) as dialogs
|
||||
- Type: `ShowDialogOptions` interface added
|
||||
|
||||
- Extended `LocalProjectsModel.ts` with runtime detection:
|
||||
|
||||
- `RuntimeVersionInfo` import from migration/types
|
||||
- `detectRuntimeVersion` import from migration/ProjectScanner
|
||||
- `ProjectItemWithRuntime` interface extending ProjectItem with runtimeInfo
|
||||
@@ -255,6 +478,7 @@ packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.module.scss
|
||||
- `clearRuntimeCache(projectPath)` - Clear cache after migration
|
||||
|
||||
- Updated `projectsview.html` template with legacy project indicators:
|
||||
|
||||
- `data-class="isLegacy:projects-item--legacy"` conditional styling
|
||||
- Legacy badge with warning SVG icon (positioned top-right)
|
||||
- Legacy actions overlay with "Migrate Project" and "Open Read-Only" buttons
|
||||
@@ -262,6 +486,7 @@ packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.module.scss
|
||||
- Detecting spinner with `data-class="isDetecting:projects-item-detecting"`
|
||||
|
||||
- Added CSS styles in `projectsview.css`:
|
||||
|
||||
- `.projects-item--legacy` - Orange border for legacy projects
|
||||
- `.projects-item-legacy-badge` - Top-right warning badge
|
||||
- `.projects-item-legacy-actions` - Hover overlay with migration buttons
|
||||
@@ -282,6 +507,7 @@ packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.module.scss
|
||||
- `onOpenReadOnlyClicked()` - Opens project normally (banner display deferred)
|
||||
|
||||
**Technical Notes:**
|
||||
|
||||
- DialogLayerModel uses existing Modal wrapper pattern with custom render function
|
||||
- Runtime detection uses in-memory cache to avoid persistence to localStorage
|
||||
- Template binding uses jQuery-based View system with `data-*` attributes
|
||||
@@ -290,6 +516,7 @@ packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.module.scss
|
||||
- ToastLayer.showSuccess() used for migration completion notification
|
||||
|
||||
**Files Modified:**
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/models/DialogLayerModel.tsx
|
||||
packages/noodl-editor/src/editor/src/utils/LocalProjectsModel.ts
|
||||
@@ -299,6 +526,7 @@ packages/noodl-editor/src/editor/src/views/projectsview.ts
|
||||
```
|
||||
|
||||
**Remaining for Future Sessions:**
|
||||
|
||||
- EditorBanner component for legacy read-only mode warning (Post-Migration UX)
|
||||
- wire open project flow for legacy detection (auto-detect on existing project open)
|
||||
|
||||
@@ -309,7 +537,9 @@ packages/noodl-editor/src/editor/src/views/projectsview.ts
|
||||
#### 2024-12-14
|
||||
|
||||
**Added:**
|
||||
|
||||
- Created `packages/noodl-editor/src/editor/src/views/migration/` directory with:
|
||||
|
||||
- `MigrationWizard.tsx` - Main wizard container component:
|
||||
- Uses Modal component from @noodl-core-ui
|
||||
- useReducer for local state management
|
||||
@@ -350,6 +580,7 @@ packages/noodl-editor/src/editor/src/views/projectsview.ts
|
||||
- `steps/FailedStep.module.scss`
|
||||
|
||||
**Technical Notes:**
|
||||
|
||||
- Text component uses `className` not `UNSAFE_className` for styling
|
||||
- Text component uses `textType` prop (TextType.Secondary, TextType.Shy) not variants
|
||||
- TextInput onChange expects standard React ChangeEventHandler<HTMLInputElement>
|
||||
@@ -359,6 +590,7 @@ packages/noodl-editor/src/editor/src/views/projectsview.ts
|
||||
- SVG icons defined inline in each component for self-containment
|
||||
|
||||
**Files Created:**
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/views/migration/
|
||||
├── MigrationWizard.tsx
|
||||
@@ -380,6 +612,7 @@ packages/noodl-editor/src/editor/src/views/migration/
|
||||
```
|
||||
|
||||
**Remaining for Session 2:**
|
||||
|
||||
- DialogLayerModel integration for showing wizard (deferred to Session 3)
|
||||
|
||||
---
|
||||
@@ -389,6 +622,7 @@ packages/noodl-editor/src/editor/src/views/migration/
|
||||
#### 2024-12-13
|
||||
|
||||
**Added:**
|
||||
|
||||
- Created CHECKLIST.md for tracking implementation progress
|
||||
- Created CHANGELOG.md for documenting changes
|
||||
- Created `packages/noodl-editor/src/editor/src/models/migration/` directory with:
|
||||
@@ -413,12 +647,14 @@ packages/noodl-editor/src/editor/src/views/migration/
|
||||
- `index.ts` - Clean module exports
|
||||
|
||||
**Technical Notes:**
|
||||
|
||||
- IFileSystem interface from `@noodl/platform` uses `readFile(path)` with single argument (no encoding)
|
||||
- IFileSystem doesn't expose file stat/birthtime - creation date heuristic relies on project.json metadata
|
||||
- Migration phases: copying → automatic → ai-assisted → finalizing
|
||||
- Default AI budget: $5 max per session, $1 pause increments
|
||||
|
||||
**Files Created:**
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/models/migration/
|
||||
├── index.ts
|
||||
@@ -434,6 +670,7 @@ packages/noodl-editor/src/editor/src/models/migration/
|
||||
This changelog tracks the implementation of the React 19 Migration System feature, which allows users to safely upgrade legacy Noodl projects (React 17) to the new OpenNoodl runtime (React 19), with optional AI-assisted code migration.
|
||||
|
||||
### Feature Specs
|
||||
|
||||
- [00-OVERVIEW.md](./00-OVERVIEW.md) - Feature summary and architecture
|
||||
- [01-PROJECT-DETECTION.md](./01-PROJECT-DETECTION.md) - Detecting legacy projects
|
||||
- [02-MIGRATION-WIZARD.md](./02-MIGRATION-WIZARD.md) - Step-by-step wizard UI
|
||||
@@ -442,6 +679,7 @@ This changelog tracks the implementation of the React 19 Migration System featur
|
||||
- [05-NEW-PROJECT-NOTICE.md](./05-NEW-PROJECT-NOTICE.md) - New project messaging
|
||||
|
||||
### Implementation Sessions
|
||||
|
||||
1. **Session 1**: Foundation + Detection (types, scanner, models)
|
||||
2. **Session 2**: Wizard UI (basic flow without AI)
|
||||
3. **Session 3**: Projects View Integration (legacy badges, buttons)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# React 19 Migration System - Implementation Checklist
|
||||
|
||||
## Session 1: Foundation + Detection
|
||||
|
||||
- [x] Create migration types file (`models/migration/types.ts`)
|
||||
- [x] Create ProjectScanner.ts (detection logic with 5-tier checks)
|
||||
- [ ] Update ProjectModel with migration fields (deferred - not needed for initial wizard)
|
||||
@@ -10,6 +11,7 @@
|
||||
- [x] Create index.ts module exports
|
||||
|
||||
## Session 2: Wizard UI (Basic Flow)
|
||||
|
||||
- [x] MigrationWizard.tsx container
|
||||
- [x] WizardProgress.tsx component
|
||||
- [x] ConfirmStep.tsx component
|
||||
@@ -22,6 +24,7 @@
|
||||
- [x] DialogLayerModel integration for showing wizard (completed in Session 3)
|
||||
|
||||
## Session 3: Projects View Integration
|
||||
|
||||
- [x] DialogLayerModel.showDialog() generic method
|
||||
- [x] LocalProjectsModel runtime detection with cache
|
||||
- [x] Update projectsview.html template with legacy badges
|
||||
@@ -35,17 +38,22 @@
|
||||
- [ ] Wire auto-detect on existing project open - deferred to Post-Migration UX
|
||||
|
||||
## Session 4: AI Migration + Polish
|
||||
- [ ] claudeClient.ts (Anthropic API integration)
|
||||
- [ ] keyStorage.ts (encrypted API key storage)
|
||||
- [ ] AIConfigPanel.tsx (API key + budget UI)
|
||||
- [ ] BudgetController.ts (spending limits)
|
||||
- [ ] BudgetApprovalDialog.tsx
|
||||
- [ ] Integration into wizard flow
|
||||
- [ ] MigratingStep.tsx with AI progress
|
||||
|
||||
- [x] claudeClient.ts (Anthropic API integration) - Completed Session 9
|
||||
- [x] keyStorage.ts (encrypted API key storage) - Completed Session 9
|
||||
- [x] claudePrompts.ts (system prompts and templates) - Completed Session 9
|
||||
- [x] AIConfigPanel.tsx (API key + budget UI) - Completed Session 9
|
||||
- [x] BudgetController.ts (spending limits) - Completed Session 9
|
||||
- [x] BudgetApprovalDialog.tsx - Completed Session 9
|
||||
- [x] AIMigrationOrchestrator.ts (multi-component coordination) - Completed Session 9
|
||||
- [x] MigratingStep.tsx with AI progress - Completed Session 10
|
||||
- [x] ReportStep.tsx AI configuration support - Completed Session 10
|
||||
- [x] Integration into wizard flow (wire MigrationWizard.tsx) - Completed Session 11
|
||||
- [ ] Post-migration component status badges
|
||||
- [ ] MigrationNotesPanel.tsx
|
||||
|
||||
## Post-Migration UX
|
||||
|
||||
- [ ] Component panel status indicators
|
||||
- [ ] Migration notes display
|
||||
- [ ] Dismiss functionality
|
||||
@@ -53,6 +61,7 @@
|
||||
- [ ] Component filter by migration status
|
||||
|
||||
## Polish Items
|
||||
|
||||
- [ ] New project dialog React 19 notice
|
||||
- [ ] Welcome dialog for version updates
|
||||
- [ ] Documentation links throughout UI
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# TASK-007 Changelog
|
||||
|
||||
## [Date TBD] - Initial Task Creation
|
||||
|
||||
### Summary
|
||||
|
||||
Created TASK-007 to document the work required to wire the AI migration backend into the MigrationSession. All AI infrastructure components (AIMigrationOrchestrator, ClaudeClient, BudgetController, AIConfigPanel, BudgetApprovalDialog) were built in TASK-004 but the integration point in `executeAIAssistedPhase()` was intentionally left as a stub.
|
||||
|
||||
### Task Documents Created
|
||||
|
||||
- `README.md` - Full task specification with background, scope, and implementation steps
|
||||
- `CHECKLIST.md` - Step-by-step checklist for implementation
|
||||
- `CHANGELOG.md` - This file
|
||||
- `NOTES.md` - Working notes template
|
||||
|
||||
### Next Steps
|
||||
|
||||
- Create branch `task/007-wire-ai-migration`
|
||||
- Begin Phase 1: Create DecisionDialog component
|
||||
- Follow checklist through to completion
|
||||
|
||||
### Known Issues
|
||||
|
||||
None yet - task not started.
|
||||
|
||||
---
|
||||
|
||||
## [Date TBD] - Implementation Progress
|
||||
|
||||
_Add entries here as implementation progresses_
|
||||
|
||||
### Files Modified
|
||||
|
||||
- `packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts` - [What changed and why]
|
||||
- `packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.tsx` - [What changed and why]
|
||||
|
||||
### Files Created
|
||||
|
||||
- `packages/noodl-editor/src/editor/src/views/migration/DecisionDialog.tsx` - [Purpose]
|
||||
- `packages/noodl-editor/src/editor/src/views/migration/DecisionDialog.module.scss` - [Purpose]
|
||||
|
||||
### Testing Notes
|
||||
|
||||
- [What was tested]
|
||||
- [Any edge cases discovered]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- None expected
|
||||
|
||||
### Known Issues
|
||||
|
||||
- [Any remaining issues or follow-up needed]
|
||||
166
dev-docs/tasks/phase-2/TASK-007-wire-ai-migration/CHECKLIST.md
Normal file
166
dev-docs/tasks/phase-2/TASK-007-wire-ai-migration/CHECKLIST.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# TASK-007 Checklist
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [x] Read README.md completely
|
||||
- [x] Understand the scope and success criteria
|
||||
- [ ] Create branch: `git checkout -b task/007-wire-ai-migration`
|
||||
- [ ] Verify build works: `npm run build:editor`
|
||||
- [ ] Verify existing AI components work in isolation
|
||||
|
||||
## Phase 1: Create DecisionDialog Component
|
||||
|
||||
- [ ] Create `DecisionDialog.tsx`
|
||||
- [ ] Define props interface (componentName, attempts, costSpent, etc.)
|
||||
- [ ] Add UI for showing retry history
|
||||
- [ ] Add 4 action buttons (Retry, Skip, Get Help, Accept Partial)
|
||||
- [ ] Handle "Get Help" to display AI suggestions
|
||||
- [ ] Export component
|
||||
- [ ] Create `DecisionDialog.module.scss`
|
||||
- [ ] Style the dialog layout
|
||||
- [ ] Style retry history display
|
||||
- [ ] Style action buttons
|
||||
- [ ] Add responsive layout
|
||||
- [ ] Document in CHANGELOG.md
|
||||
|
||||
## Phase 2: Wire Budget Approval Flow
|
||||
|
||||
- [ ] Modify `MigrationWizard.tsx`
|
||||
- [ ] Add `budgetApprovalRequest` state
|
||||
- [ ] Create `handleBudgetApproval` callback
|
||||
- [ ] Create `handleBudgetDenial` callback
|
||||
- [ ] Pass callbacks to MigratingStep
|
||||
- [ ] Modify `MigratingStep.tsx`
|
||||
- [ ] Import BudgetApprovalDialog
|
||||
- [ ] Receive budget callback props
|
||||
- [ ] Conditionally render BudgetApprovalDialog
|
||||
- [ ] Connect approve/deny to callbacks
|
||||
- [ ] Test budget approval flow manually
|
||||
- [ ] Document in CHANGELOG.md
|
||||
|
||||
## Phase 3: Wire Decision Flow
|
||||
|
||||
- [ ] Modify `MigrationWizard.tsx`
|
||||
- [ ] Add `decisionRequest` state
|
||||
- [ ] Create `handleDecision` callback
|
||||
- [ ] Pass callbacks to MigratingStep
|
||||
- [ ] Modify `MigratingStep.tsx`
|
||||
- [ ] Import DecisionDialog
|
||||
- [ ] Receive decision callback props
|
||||
- [ ] Conditionally render DecisionDialog
|
||||
- [ ] Handle all 4 decision types
|
||||
- [ ] Display AI help text if provided
|
||||
- [ ] Test decision flow manually
|
||||
- [ ] Document in CHANGELOG.md
|
||||
|
||||
## Phase 4: Implement executeAIAssistedPhase()
|
||||
|
||||
- [ ] Open `MigrationSession.ts`
|
||||
- [ ] Import required dependencies
|
||||
- [ ] Import AIMigrationOrchestrator
|
||||
- [ ] Import filesystem for reading/writing
|
||||
- [ ] Import types (DecisionRequest, ProgressUpdate, etc.)
|
||||
- [ ] Replace stub implementation
|
||||
- [ ] Remove TODO comment and simulateDelay
|
||||
- [ ] Initialize AIMigrationOrchestrator with API key and budget
|
||||
- [ ] Create onBudgetPause callback that notifies listeners
|
||||
- [ ] Create onProgress callback that updates progress
|
||||
- [ ] Create onDecisionRequired callback that notifies listeners
|
||||
- [ ] Loop through needsReview components
|
||||
- [ ] Get component file path
|
||||
- [ ] Read source code using filesystem
|
||||
- [ ] Call orchestrator.migrateComponent()
|
||||
- [ ] Handle 'success' result: write to target, log success
|
||||
- [ ] Handle 'partial' result: write code, log warning
|
||||
- [ ] Handle 'failed' result: log error, don't write
|
||||
- [ ] Handle 'skipped' result: log info
|
||||
- [ ] Update budget spending in session.ai.budget.spent
|
||||
- [ ] Add error handling with try-catch
|
||||
- [ ] Ensure orchestrator is cleaned up
|
||||
- [ ] Document in CHANGELOG.md
|
||||
|
||||
## Phase 5: Handle Session Abort
|
||||
|
||||
- [ ] Add orchestrator instance tracking in MigrationSessionManager
|
||||
- [ ] Modify `cancelSession()` method
|
||||
- [ ] Call orchestrator.abort() if exists
|
||||
- [ ] Clean up orchestrator reference
|
||||
- [ ] Modify `startMigration()` error handling
|
||||
- [ ] Ensure orchestrator aborts on error
|
||||
- [ ] Clean up resources
|
||||
- [ ] Test abort scenarios
|
||||
- [ ] Document in CHANGELOG.md
|
||||
|
||||
## Phase 6: Manual Testing
|
||||
|
||||
### Scenario 1: Successful Migration
|
||||
|
||||
- [ ] Set up test project with 1 needsReview component
|
||||
- [ ] Configure valid API key
|
||||
- [ ] Set $2 budget
|
||||
- [ ] Start migration
|
||||
- [ ] Verify Claude API is called
|
||||
- [ ] Verify migrated code is written to target
|
||||
- [ ] Verify success log entry
|
||||
- [ ] Verify budget spending tracked
|
||||
|
||||
### Scenario 2: Budget Pause
|
||||
|
||||
- [ ] Configure $2 budget with $0.50 pause increment
|
||||
- [ ] Scan project with multiple components
|
||||
- [ ] Start migration
|
||||
- [ ] Wait for BudgetApprovalDialog
|
||||
- [ ] Test "Stop Here" button
|
||||
- [ ] Restart and test "Continue" button
|
||||
|
||||
### Scenario 3: Failed Migration
|
||||
|
||||
- [ ] Find or create complex component likely to fail
|
||||
- [ ] Start migration
|
||||
- [ ] Observe retry attempts in log
|
||||
- [ ] Wait for DecisionDialog
|
||||
- [ ] Test "Retry" button
|
||||
- [ ] Test "Skip" button
|
||||
- [ ] Test "Get Help" button (verify AI suggestions shown)
|
||||
- [ ] Test "Accept Partial" button
|
||||
|
||||
### Scenario 4: Abort During Migration
|
||||
|
||||
- [ ] Start migration
|
||||
- [ ] Click cancel mid-migration
|
||||
- [ ] Verify orchestrator stops
|
||||
- [ ] Verify partial results saved
|
||||
- [ ] Verify budget tracking accurate
|
||||
|
||||
### Scenario 5: Invalid API Key
|
||||
|
||||
- [ ] Configure invalid/expired API key
|
||||
- [ ] Try to migrate
|
||||
- [ ] Verify clear error message
|
||||
- [ ] Verify session not in broken state
|
||||
- [ ] Verify can reconfigure and retry
|
||||
|
||||
## Phase 7: Code Quality
|
||||
|
||||
- [ ] Run type check: `npx tsc --noEmit`
|
||||
- [ ] Fix any TypeScript errors
|
||||
- [ ] Run linter: `npx eslint packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts --fix`
|
||||
- [ ] Fix any ESLint warnings
|
||||
- [ ] Add JSDoc comments to new public methods
|
||||
- [ ] Remove any debug console.log statements
|
||||
- [ ] Ensure all imports are used
|
||||
|
||||
## Phase 8: Documentation
|
||||
|
||||
- [ ] Update CHANGELOG.md with final summary
|
||||
- [ ] Add discoveries to NOTES.md
|
||||
- [ ] Update TASK-004 CHANGELOG noting TASK-007 completion
|
||||
- [ ] Check if any dev-docs/reference/ files need updates
|
||||
|
||||
## Phase 9: Completion
|
||||
|
||||
- [ ] Self-review all changes
|
||||
- [ ] Verify all success criteria met
|
||||
- [ ] Verify all checklist items completed
|
||||
- [ ] Commit changes with proper message
|
||||
- [ ] Mark task as complete in phase-2 tracking
|
||||
181
dev-docs/tasks/phase-2/TASK-007-wire-ai-migration/NOTES.md
Normal file
181
dev-docs/tasks/phase-2/TASK-007-wire-ai-migration/NOTES.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# TASK-007 Working Notes
|
||||
|
||||
## Research
|
||||
|
||||
### Existing Patterns Found
|
||||
|
||||
#### AIMigrationOrchestrator Interface
|
||||
|
||||
- Located in: `packages/noodl-editor/src/editor/src/models/migration/AIMigrationOrchestrator.ts`
|
||||
- Constructor signature:
|
||||
|
||||
```typescript
|
||||
constructor(
|
||||
apiKey: string,
|
||||
budgetConfig: AIConfig['budget'],
|
||||
config: OrchestratorConfig,
|
||||
onBudgetPause: (state: BudgetState) => Promise<boolean>
|
||||
)
|
||||
```
|
||||
|
||||
- Main method: `migrateComponent(component, code, preferences, onProgress, onDecisionRequired)`
|
||||
- Returns: `ComponentMigrationResult`
|
||||
|
||||
#### Callback Patterns
|
||||
|
||||
The orchestrator expects two key callbacks:
|
||||
|
||||
1. **onBudgetPause**: `(state: BudgetState) => Promise<boolean>`
|
||||
|
||||
- Called when spending reaches pause increment
|
||||
- Should show BudgetApprovalDialog
|
||||
- Returns true to continue, false to stop
|
||||
|
||||
2. **onDecisionRequired**: `(request: DecisionRequest) => Promise<Decision>`
|
||||
- Called after max retries exhausted
|
||||
- Should show DecisionDialog
|
||||
- Returns user's choice (retry/skip/getHelp/manual)
|
||||
|
||||
#### Event Flow in MigrationSession
|
||||
|
||||
MigrationSession uses EventDispatcher pattern:
|
||||
|
||||
- Notifies listeners with `this.notifyListeners(eventName, payload)`
|
||||
- MigrationWizard subscribes to events
|
||||
- Events include: 'sessionCreated', 'stepChanged', 'scanProgress', 'progressUpdated', 'logEntry', etc.
|
||||
|
||||
### Questions to Resolve
|
||||
|
||||
- [x] Where are DecisionRequest and Decision types defined?
|
||||
- Answer: Should be in `packages/noodl-editor/src/editor/src/models/migration/types.ts`
|
||||
- [x] How does MigrationWizard subscribe to session events?
|
||||
- Answer: Uses `migrationSessionManager.on(eventName, handler)` pattern
|
||||
- [x] Where do we read source code files from?
|
||||
- Answer: From `session.source.path` (original project), not target (copy)
|
||||
- [x] How do we construct file paths for components?
|
||||
- Answer: ComponentMigrationInfo should have a `filePath` property from scan results
|
||||
|
||||
### Assumptions
|
||||
|
||||
- Assumption 1: ComponentMigrationInfo includes `filePath` property - ✅ Need to verify in types.ts
|
||||
- Assumption 2: Target path already has directory structure copied - ✅ Validated (executeCopyPhase does this)
|
||||
- Assumption 3: BudgetApprovalDialog can be shown during migration - ✅ Validated (async callback pattern supports this)
|
||||
- Assumption 4: filesystem.readFile returns string content - ✅ Need to verify API
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Approach Decisions
|
||||
|
||||
#### Why Not Use React State for Budget/Decision Dialogs?
|
||||
|
||||
**Decision**: Use EventDispatcher pattern instead of lifting state to MigrationWizard
|
||||
|
||||
**Reasoning**:
|
||||
|
||||
- Keeps orchestrator decoupled from React
|
||||
- Follows existing pattern used for scan progress
|
||||
- Makes testing easier (can test orchestrator without UI)
|
||||
|
||||
#### Where to Store Orchestrator Instance?
|
||||
|
||||
**Decision**: Store as class property in MigrationSessionManager
|
||||
|
||||
**Reasoning**:
|
||||
|
||||
- Needs to be accessible to cancelSession() for abort
|
||||
- Scoped to migration lifecycle
|
||||
- Follows pattern used for session state
|
||||
|
||||
### File Reading Strategy
|
||||
|
||||
```typescript
|
||||
// Read from SOURCE (original project)
|
||||
const sourcePath = `${this.session.source.path}/${component.filePath}`;
|
||||
const code = await filesystem.readFile(sourcePath);
|
||||
|
||||
// Write to TARGET (copy)
|
||||
const targetPath = `${this.session.target.path}/${component.filePath}`;
|
||||
await filesystem.writeFile(targetPath, migratedCode);
|
||||
```
|
||||
|
||||
### Event Names to Add
|
||||
|
||||
Need to add new events to MigrationSessionManager:
|
||||
|
||||
- `budgetPauseRequired` - When orchestrator needs budget approval
|
||||
- `decisionRequired` - When orchestrator needs retry decision
|
||||
- `componentMigrated` - After each component completes (for progress updates)
|
||||
|
||||
### Gotchas / Surprises
|
||||
|
||||
- None yet - implementation not started
|
||||
|
||||
### Useful Commands
|
||||
|
||||
```bash
|
||||
# Search for ComponentMigrationInfo type definition
|
||||
grep -r "interface ComponentMigrationInfo" packages/noodl-editor/
|
||||
|
||||
# Find how filesystem API is used
|
||||
grep -r "filesystem.readFile" packages/noodl-editor/src/editor/
|
||||
|
||||
# Check existing event listener patterns
|
||||
grep -r "migrationSessionManager.on" packages/noodl-editor/src/editor/
|
||||
|
||||
# Test build after changes
|
||||
npm run build:editor
|
||||
|
||||
# Type check only
|
||||
npx tsc --noEmit -p packages/noodl-editor/tsconfig.json
|
||||
```
|
||||
|
||||
## Debug Log
|
||||
|
||||
_Add entries as you work through implementation_
|
||||
|
||||
### [Date/Time] - Phase 1: DecisionDialog Component
|
||||
|
||||
- Trying: [what you're attempting]
|
||||
- Result: [what happened]
|
||||
- Next: [what to try next]
|
||||
|
||||
### [Date/Time] - Phase 2: Budget Approval Flow
|
||||
|
||||
- Trying: [what you're attempting]
|
||||
- Result: [what happened]
|
||||
- Next: [what to try next]
|
||||
|
||||
### [Date/Time] - Phase 3: Decision Flow
|
||||
|
||||
- Trying: [what you're attempting]
|
||||
- Result: [what happened]
|
||||
- Next: [what to try next]
|
||||
|
||||
### [Date/Time] - Phase 4: executeAIAssistedPhase Implementation
|
||||
|
||||
- Trying: [what you're attempting]
|
||||
- Result: [what happened]
|
||||
- Next: [what to try next]
|
||||
|
||||
## Discoveries for LEARNINGS.md
|
||||
|
||||
_Note any non-obvious patterns or gotchas discovered during implementation that should be added to dev-docs/reference/LEARNINGS.md_
|
||||
|
||||
### Example Discovery Template
|
||||
|
||||
**Context**: What were you trying to do?
|
||||
|
||||
**Discovery**: What did you learn?
|
||||
|
||||
**Location**: What files/areas does this apply to?
|
||||
|
||||
**Keywords**: [searchable terms for future reference]
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [AIMigrationOrchestrator.ts](../../../packages/noodl-editor/src/editor/src/models/migration/AIMigrationOrchestrator.ts)
|
||||
- [BudgetController.ts](../../../packages/noodl-editor/src/editor/src/models/migration/BudgetController.ts)
|
||||
- [MigrationSession.ts](../../../packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts)
|
||||
- [types.ts](../../../packages/noodl-editor/src/editor/src/models/migration/types.ts)
|
||||
348
dev-docs/tasks/phase-2/TASK-007-wire-ai-migration/README.md
Normal file
348
dev-docs/tasks/phase-2/TASK-007-wire-ai-migration/README.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# TASK-007: Wire AI Migration Backend
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------- |
|
||||
| **ID** | TASK-007 |
|
||||
| **Phase** | Phase 2 |
|
||||
| **Priority** | 🟠 High |
|
||||
| **Difficulty** | 🟡 Medium |
|
||||
| **Estimated Time** | 3-4 hours |
|
||||
| **Prerequisites** | TASK-004 (complete) |
|
||||
| **Branch** | `task/007-wire-ai-migration` |
|
||||
|
||||
## Objective
|
||||
|
||||
Connect the fully-built AI migration infrastructure (AIMigrationOrchestrator, ClaudeClient, BudgetController) to the MigrationSession's `executeAIAssistedPhase()` stub to enable end-to-end AI-powered component migration.
|
||||
|
||||
## Background
|
||||
|
||||
TASK-004 implemented a comprehensive React 19 migration system with optional AI assistance. During development, all the AI infrastructure components were built and tested:
|
||||
|
||||
- **ClaudeClient**: Handles Anthropic API communication with proper prompt engineering
|
||||
- **BudgetController**: Manages spending limits, pause increments, and approval flow
|
||||
- **AIMigrationOrchestrator**: Coordinates component migration with retry logic, verification, and decision points
|
||||
- **AIConfigPanel**: UI for configuring API key, budget, and preferences
|
||||
- **BudgetApprovalDialog**: UI for budget pause confirmations
|
||||
- **keyStorage**: Encrypted API key storage with Electron safeStorage
|
||||
|
||||
However, the actual integration point in `MigrationSession.executeAIAssistedPhase()` was intentionally left as a stub with a TODO comment. This was done to:
|
||||
|
||||
1. Allow the UI and scanning flow to be tested independently
|
||||
2. Avoid consuming API credits during development
|
||||
3. Defer the wiring work to a focused integration task
|
||||
|
||||
**Current Behavior:**
|
||||
When a user clicks "Migrate with AI", the system:
|
||||
|
||||
- Immediately marks all `needsReview` components as "AI migration not yet implemented"
|
||||
- Completes the migration instantly without calling Claude
|
||||
- Logs warnings instead of performing actual migrations
|
||||
|
||||
**The Problem:**
|
||||
Users who configure an API key and budget cannot actually use AI migration, making the entire AI feature non-functional.
|
||||
|
||||
## Current State
|
||||
|
||||
### Working Components
|
||||
|
||||
- ✅ AI configuration UI (AIConfigPanel)
|
||||
- ✅ API key validation and encrypted storage
|
||||
- ✅ Budget configuration UI
|
||||
- ✅ ClaudeClient with prompt engineering
|
||||
- ✅ BudgetController with spending tracking
|
||||
- ✅ AIMigrationOrchestrator with retry logic
|
||||
- ✅ BudgetApprovalDialog component
|
||||
- ✅ Scan results correctly identify `needsReview` components
|
||||
|
||||
### Non-Working Code Path
|
||||
|
||||
- ❌ `MigrationSession.executeAIAssistedPhase()` is a stub (lines ~500-520)
|
||||
- ❌ Orchestrator never initialized
|
||||
- ❌ Claude API never called
|
||||
- ❌ Budget approval dialog never shown
|
||||
- ❌ Decision dialog for failed retries doesn't exist
|
||||
- ❌ Migrated code never written to target files
|
||||
|
||||
### Current Stub Implementation
|
||||
|
||||
```typescript
|
||||
private async executeAIAssistedPhase(): Promise<void> {
|
||||
if (!this.session?.scan || !this.session.ai?.enabled) return;
|
||||
|
||||
this.updateProgress({ phase: 'ai-assisted' });
|
||||
this.addLogEntry({
|
||||
level: 'info',
|
||||
message: 'Starting AI-assisted migration...'
|
||||
});
|
||||
|
||||
const { needsReview } = this.session.scan.categories;
|
||||
|
||||
for (let i = 0; i < needsReview.length; i++) {
|
||||
const component = needsReview[i];
|
||||
|
||||
this.updateProgress({
|
||||
currentComponent: component.name
|
||||
});
|
||||
|
||||
// TODO: Implement actual AI migration using Claude API
|
||||
await this.simulateDelay(200);
|
||||
|
||||
this.addLogEntry({
|
||||
level: 'warning',
|
||||
component: component.name,
|
||||
message: 'AI migration not yet implemented - marked for manual review'
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Desired State
|
||||
|
||||
When a user clicks "Migrate with AI" after configuring their API key and budget:
|
||||
|
||||
1. **Orchestrator Initialization**: AIMigrationOrchestrator is created with the user's API key and budget settings
|
||||
2. **Component Processing**: Each `needsReview` component is sent to Claude for migration
|
||||
3. **Budget Pauses**: BudgetApprovalDialog appears when spending reaches pause increments
|
||||
4. **Retry Logic**: Failed migrations are automatically retried with different approaches
|
||||
5. **Decision Points**: After max retries, user chooses to retry, skip, get help, or accept partial result
|
||||
6. **Result Application**: Successfully migrated code is written to target files
|
||||
7. **Accurate Reporting**: Final counts reflect actual migration outcomes, not stubs
|
||||
|
||||
### User Experience Flow
|
||||
|
||||
```
|
||||
[Click "Migrate with AI"]
|
||||
↓
|
||||
[Orchestrator initializes]
|
||||
↓
|
||||
[For each component needing AI:]
|
||||
├─ Read source code from original location
|
||||
├─ Send to Claude with migration request
|
||||
├─ [Budget pause?] → Show BudgetApprovalDialog
|
||||
├─ [Success?] → Write migrated code to target
|
||||
└─ [Failed after retries?] → Show DecisionDialog
|
||||
↓
|
||||
[Display accurate results]
|
||||
```
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- [x] Initialize AIMigrationOrchestrator in `executeAIAssistedPhase()`
|
||||
- [x] Read source code for each `needsReview` component
|
||||
- [x] Call `orchestrator.migrateComponent()` with proper callbacks
|
||||
- [x] Implement `onBudgetPause` callback to show approval dialog
|
||||
- [x] Implement `onDecisionRequired` callback for retry decisions
|
||||
- [x] Create DecisionDialog component for failed migrations
|
||||
- [x] Write successful migrations to target files
|
||||
- [x] Update progress log with real results
|
||||
- [x] Track actual budget spending
|
||||
- [x] Handle orchestrator abort on session cancel
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Improving Claude prompts (already done in TASK-004)
|
||||
- Changing budget UI (already done in TASK-004)
|
||||
- Adding new migration strategies (orchestrator already supports them)
|
||||
- Changing the scan logic (TASK-004 complete)
|
||||
- Optimizing API costs (can be a future task)
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Key Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
|
||||
| `packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts` | Replace `executeAIAssistedPhase()` stub with real orchestrator integration |
|
||||
| `packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.tsx` | Add state for budget/decision dialogs, wire callbacks |
|
||||
| `packages/noodl-editor/src/editor/src/views/migration/steps/MigratingStep.tsx` | Display budget approval and decision dialogs during migration |
|
||||
|
||||
### New Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| `packages/noodl-editor/src/editor/src/views/migration/DecisionDialog.tsx` | UI for choosing what to do after failed migration retries |
|
||||
| `packages/noodl-editor/src/editor/src/views/migration/DecisionDialog.module.scss` | Styles for DecisionDialog |
|
||||
|
||||
### Dependencies
|
||||
|
||||
- ✅ `AIMigrationOrchestrator` (already exists)
|
||||
- ✅ `ClaudeClient` (already exists)
|
||||
- ✅ `BudgetController` (already exists)
|
||||
- ✅ `BudgetApprovalDialog` (already exists)
|
||||
- ✅ API key storage (already exists)
|
||||
- ✅ Types for all interfaces (already exists in `types.ts`)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create DecisionDialog Component
|
||||
|
||||
Create the UI for handling failed migration retries. User can choose to:
|
||||
|
||||
- **Retry**: Start over with fresh retry attempts
|
||||
- **Skip**: Mark component for manual review
|
||||
- **Get Help**: Show AI's detailed manual migration instructions
|
||||
- **Accept Partial**: Use the last attempted migration (may need fixes)
|
||||
|
||||
### Step 2: Wire Budget Approval Flow
|
||||
|
||||
Modify MigrationWizard and MigratingStep to:
|
||||
|
||||
- Store budget approval state
|
||||
- Show BudgetApprovalDialog when orchestrator pauses
|
||||
- Return approval decision to orchestrator
|
||||
- Resume or abort based on user choice
|
||||
|
||||
### Step 3: Wire Decision Flow
|
||||
|
||||
Modify MigrationWizard and MigratingStep to:
|
||||
|
||||
- Store decision request state
|
||||
- Show DecisionDialog when retries exhausted
|
||||
- Handle user's choice (retry/skip/help/manual)
|
||||
- Display AI help text if requested
|
||||
|
||||
### Step 4: Implement executeAIAssistedPhase()
|
||||
|
||||
Replace the stub with real implementation:
|
||||
|
||||
- Initialize AIMigrationOrchestrator with API key and budget
|
||||
- Loop through `needsReview` components
|
||||
- Read source code using filesystem
|
||||
- Call `orchestrator.migrateComponent()` with callbacks
|
||||
- Handle all possible result types (success/partial/failed/skipped)
|
||||
- Write successful migrations to target files
|
||||
- Update progress and logs accurately
|
||||
|
||||
### Step 5: Handle Session Abort
|
||||
|
||||
Ensure orchestrator stops cleanly if:
|
||||
|
||||
- User cancels migration
|
||||
- Error occurs
|
||||
- Budget exhausted
|
||||
|
||||
### Step 6: End-to-End Testing
|
||||
|
||||
Test the complete flow with real API calls:
|
||||
|
||||
- Small budget (e.g., $0.50) to test pause behavior
|
||||
- Component that should succeed
|
||||
- Component that might fail (to test retry/decision flow)
|
||||
- Verify budget tracking is accurate
|
||||
- Verify migrated code is written correctly
|
||||
|
||||
## Testing Plan
|
||||
|
||||
### Manual Testing Scenarios
|
||||
|
||||
#### Scenario 1: Successful Migration
|
||||
|
||||
- [ ] Configure API key and $2 budget
|
||||
- [ ] Scan project with 1-2 simple `needsReview` components
|
||||
- [ ] Click "Migrate with AI"
|
||||
- [ ] Verify Claude is called
|
||||
- [ ] Verify migrated code is written to target
|
||||
- [ ] Verify success log entries
|
||||
- [ ] Verify budget spending is tracked
|
||||
|
||||
#### Scenario 2: Budget Pause
|
||||
|
||||
- [ ] Configure API key with $2 budget and $0.50 pause increment
|
||||
- [ ] Scan project with 5+ components needing AI
|
||||
- [ ] Click "Migrate with AI"
|
||||
- [ ] Verify BudgetApprovalDialog appears after ~$0.50 spent
|
||||
- [ ] Click "Stop Here" and verify migration stops
|
||||
- [ ] Retry, click "Continue" and verify migration resumes
|
||||
|
||||
#### Scenario 3: Failed Migration with Retry
|
||||
|
||||
- [ ] Configure API key
|
||||
- [ ] Scan project with complex component likely to fail
|
||||
- [ ] Click "Migrate with AI"
|
||||
- [ ] Verify retry attempts are logged
|
||||
- [ ] Verify DecisionDialog appears after max retries
|
||||
- [ ] Test each decision option:
|
||||
- "Retry" → migration starts over
|
||||
- "Skip" → component marked for manual review
|
||||
- "Get Help" → AI suggestions displayed
|
||||
- "Accept Partial" → last attempt code is used
|
||||
|
||||
#### Scenario 4: Abort During Migration
|
||||
|
||||
- [ ] Start AI migration
|
||||
- [ ] Click cancel button mid-migration
|
||||
- [ ] Verify orchestrator stops cleanly
|
||||
- [ ] Verify partial results are saved
|
||||
- [ ] Verify budget tracking is correct
|
||||
|
||||
#### Scenario 5: API Key Invalid
|
||||
|
||||
- [ ] Configure invalid API key
|
||||
- [ ] Try to migrate
|
||||
- [ ] Verify clear error message
|
||||
- [ ] Verify session doesn't enter broken state
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- [ ] Mock ClaudeClient and test orchestrator integration
|
||||
- [ ] Test budget controller callback flow
|
||||
- [ ] Test decision flow callback
|
||||
- [ ] Test file writing with mock filesystem
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] AIMigrationOrchestrator initializes with correct API key and budget
|
||||
- [x] Each `needsReview` component is sent to Claude API
|
||||
- [x] Budget approval dialog appears at spending thresholds
|
||||
- [x] User can approve/deny additional spending
|
||||
- [x] Decision dialog appears after failed retries
|
||||
- [x] All decision actions (retry/skip/help/manual) work
|
||||
- [x] Successful migrations are written to target files
|
||||
- [x] Migration log shows accurate results (not stub warnings)
|
||||
- [x] Budget spending is tracked accurately
|
||||
- [x] Orchestrator aborts cleanly on session cancel
|
||||
- [x] End-to-end test with real Claude API succeeds
|
||||
- [x] All TypeScript types are satisfied
|
||||
- [x] No console errors during migration
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| ------------------------------------------- | -------------------------------------------------------------- |
|
||||
| API costs during testing | Use small budgets ($0.50-$1.00), test with minimal projects |
|
||||
| API rate limits | Implement exponential backoff in ClaudeClient (already exists) |
|
||||
| Invalid API key at runtime | Validate key before starting migration, clear error handling |
|
||||
| Budget dialog blocks UI thread | Use async callbacks, ensure dialogs are dismissible |
|
||||
| Failed migrations corrupt files | Write to target (copy) not source, verify before writing |
|
||||
| Orchestrator memory leak on long migrations | Ensure orchestrator is disposed after migration |
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues are discovered after wiring:
|
||||
|
||||
1. **Immediate Rollback**:
|
||||
|
||||
```typescript
|
||||
// In executeAIAssistedPhase(), wrap in try-catch
|
||||
try {
|
||||
// New orchestrator code
|
||||
} catch (error) {
|
||||
// Fall back to stub behavior
|
||||
this.addLogEntry({ level: 'error', message: error.message });
|
||||
}
|
||||
```
|
||||
|
||||
2. **File Restoration**: Target files are in a copy, source never modified
|
||||
3. **Re-disable AI**: Set `ai.enabled = false` in config if orchestrator fails
|
||||
4. **Revert Commits**: `git revert` the wiring changes
|
||||
|
||||
## References
|
||||
|
||||
- [TASK-004: Runtime Migration System](./TASK-004-runtime-migration-system/README.md) - Prerequisite task
|
||||
- [03-AI-MIGRATION.md](./TASK-004-runtime-migration-system/03-AI-MIGRATION.md) - Full AI architecture spec
|
||||
- [Anthropic API Documentation](https://docs.anthropic.com/claude/reference/getting-started-with-the-api)
|
||||
- [React 19 Migration Guide](https://react.dev/blog/2024/04/25/react-19-upgrade-guide)
|
||||
45
package-lock.json
generated
45
package-lock.json
generated
@@ -286,6 +286,26 @@
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz",
|
||||
"integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
@@ -600,7 +620,6 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -610,7 +629,6 @@
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -659,7 +677,6 @@
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
|
||||
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.5"
|
||||
@@ -2210,7 +2227,6 @@
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
|
||||
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
@@ -17812,6 +17828,19 @@
|
||||
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
|
||||
"license": "(AFL-2.1 OR BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/json-schema-to-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@@ -26449,6 +26478,12 @@
|
||||
"utf8-byte-length": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
|
||||
@@ -28666,6 +28701,8 @@
|
||||
"packages/noodl-editor": {
|
||||
"version": "1.1.0",
|
||||
"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",
|
||||
|
||||
@@ -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> {
|
||||
@@ -553,7 +539,7 @@ export class MigrationSessionManager extends EventDispatcher {
|
||||
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';
|
||||
@@ -565,10 +551,7 @@ export class MigrationSessionManager extends EventDispatcher {
|
||||
};
|
||||
|
||||
// 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,
|
||||
@@ -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