Started tasks to migrate runtime to React 19. Added phase 3 projects

This commit is contained in:
Richard Osborne
2025-12-13 22:37:44 +01:00
parent 8dd4f395c0
commit 1477a29ff7
55 changed files with 49205 additions and 281 deletions

View File

@@ -0,0 +1,205 @@
# React 19 Migration System - Implementation Overview
## Feature Summary
A comprehensive migration system that allows users to safely upgrade legacy Noodl projects (React 17) to the new OpenNoodl runtime (React 19), with optional AI-assisted code migration.
## Core Principles
1. **Never modify originals** - All migrations create a copy first
2. **Transparent progress** - Users see exactly what's happening and why
3. **Graceful degradation** - Partial success is still useful
4. **Cost consent** - AI assistance is opt-in with explicit budgets
5. **No dead ends** - Every failure state has a clear next step
## Feature Components
| Spec | Description | Priority |
|------|-------------|----------|
| [01-PROJECT-DETECTION](./01-PROJECT-DETECTION.md) | Detecting legacy projects and visual indicators | P0 |
| [02-MIGRATION-WIZARD](./02-MIGRATION-WIZARD.md) | The migration flow UI and logic | P0 |
| [03-AI-MIGRATION](./03-AI-MIGRATION.md) | AI-assisted code migration system | P1 |
| [04-POST-MIGRATION-UX](./04-POST-MIGRATION-UX.md) | Editor experience after migration | P0 |
| [05-NEW-PROJECT-NOTICE](./05-NEW-PROJECT-NOTICE.md) | Messaging for new project creation | P2 |
## Implementation Order
### Phase 1: Core Migration (No AI)
1. Project detection and version checking
2. Migration wizard UI (scan, report, execute)
3. Automatic migrations (no code changes needed)
4. Post-migration indicators in editor
### Phase 2: AI-Assisted Migration
1. API key configuration and storage
2. Budget control system
3. Claude integration for code migration
4. Retry logic and failure handling
### Phase 3: Polish
1. New project messaging
2. Migration log viewer
3. "Dismiss" functionality for warnings
4. Help documentation links
## Data Structures
### Project Manifest Addition
```typescript
// Added to project.json
interface ProjectManifest {
// Existing fields...
// New migration tracking
runtimeVersion?: 'react17' | 'react19';
migratedFrom?: {
version: 'react17';
date: string;
originalPath: string;
aiAssisted: boolean;
};
migrationNotes?: {
[componentId: string]: ComponentMigrationNote;
};
}
interface ComponentMigrationNote {
status: 'auto' | 'ai-migrated' | 'needs-review' | 'manually-fixed';
issues?: string[];
aiSuggestion?: string;
dismissedAt?: string;
}
```
### Migration Session State
```typescript
interface MigrationSession {
id: string;
sourceProject: {
path: string;
name: string;
version: 'react17';
};
targetPath: string;
status: 'scanning' | 'reporting' | 'migrating' | 'complete' | 'failed';
scan?: MigrationScan;
progress?: MigrationProgress;
result?: MigrationResult;
aiConfig?: AIConfig;
}
interface MigrationScan {
totalComponents: number;
totalNodes: number;
customJsFiles: number;
categories: {
automatic: ComponentInfo[];
simpleFixes: ComponentInfo[];
needsReview: ComponentInfo[];
};
}
interface ComponentInfo {
id: string;
name: string;
path: string;
issues: MigrationIssue[];
}
interface MigrationIssue {
type: 'componentWillMount' | 'componentWillReceiveProps' |
'componentWillUpdate' | 'stringRef' | 'legacyContext' |
'createFactory' | 'other';
description: string;
location: { file: string; line: number; };
autoFixable: boolean;
estimatedAiCost?: number;
}
```
## File Structure
```
packages/noodl-editor/src/
├── editor/src/
│ ├── models/
│ │ └── migration/
│ │ ├── MigrationSession.ts
│ │ ├── ProjectScanner.ts
│ │ ├── MigrationExecutor.ts
│ │ └── AIAssistant.ts
│ ├── views/
│ │ └── migration/
│ │ ├── MigrationWizard.tsx
│ │ ├── ScanProgress.tsx
│ │ ├── MigrationReport.tsx
│ │ ├── AIConfigPanel.tsx
│ │ ├── MigrationProgress.tsx
│ │ └── MigrationComplete.tsx
│ └── utils/
│ └── migration/
│ ├── codeAnalyzer.ts
│ ├── codeTransformer.ts
│ └── costEstimator.ts
```
## Dependencies
### New Dependencies Needed
```json
{
"@anthropic-ai/sdk": "^0.24.0",
"@babel/parser": "^7.24.0",
"@babel/traverse": "^7.24.0",
"@babel/generator": "^7.24.0"
}
```
### Why These Dependencies
- **@anthropic-ai/sdk** - Official Anthropic SDK for Claude API calls
- **@babel/*** - Parse and transform JavaScript/JSX for code analysis and automatic fixes
## Security Considerations
1. **API Key Storage**
- Store in electron-store with encryption
- Never log or transmit to OpenNoodl servers
- Clear option to remove stored key
2. **Cost Controls**
- Hard budget limits enforced client-side
- Cannot be bypassed without explicit user action
- Clear display of costs before and after
3. **Code Execution**
- AI-generated code is shown to user before applying
- Verification step before saving changes
- Full undo capability via project copy
## Testing Strategy
### Unit Tests
- ProjectScanner correctly identifies all issue types
- Cost estimator accuracy within 20%
- Code transformer handles edge cases
### Integration Tests
- Full migration flow with mock AI responses
- Budget controls enforce limits
- Project copy is byte-identical to original
### Manual Testing
- Test with real legacy Noodl projects
- Test with projects containing various issue types
- Test AI migration with real API calls (budget: $5)
## Success Metrics
- 95% of projects with only built-in nodes migrate automatically
- AI successfully migrates 80% of custom code on first attempt
- Zero data loss incidents
- Average migration time < 5 minutes for typical project

View File

@@ -0,0 +1,533 @@
# 01 - Project Detection and Visual Indicators
## Overview
Detect legacy React 17 projects and display clear visual indicators throughout the UI so users understand which projects need migration.
## Detection Logic
### When to Check
1. **On app startup** - Scan recent projects list
2. **On "Open Project"** - Check selected folder
3. **On project list refresh** - Re-scan visible projects
### How to Detect Runtime Version
```typescript
// packages/noodl-editor/src/editor/src/models/migration/ProjectScanner.ts
interface RuntimeVersionInfo {
version: 'react17' | 'react19' | 'unknown';
confidence: 'high' | 'medium' | 'low';
indicators: string[];
}
async function detectRuntimeVersion(projectPath: string): Promise<RuntimeVersionInfo> {
const indicators: string[] = [];
// Check 1: Explicit version in project.json (most reliable)
const projectJson = await readProjectJson(projectPath);
if (projectJson.runtimeVersion) {
return {
version: projectJson.runtimeVersion,
confidence: 'high',
indicators: ['Explicit runtimeVersion field in project.json']
};
}
// Check 2: Look for migratedFrom field (indicates already migrated)
if (projectJson.migratedFrom) {
return {
version: 'react19',
confidence: 'high',
indicators: ['Project has migratedFrom metadata']
};
}
// Check 3: Check project version number
// OpenNoodl 1.2+ = React 19, earlier = React 17
const editorVersion = projectJson.editorVersion || projectJson.version;
if (editorVersion) {
const [major, minor] = editorVersion.split('.').map(Number);
if (major >= 1 && minor >= 2) {
indicators.push(`Editor version ${editorVersion} >= 1.2`);
return { version: 'react19', confidence: 'high', indicators };
} else {
indicators.push(`Editor version ${editorVersion} < 1.2`);
return { version: 'react17', confidence: 'high', indicators };
}
}
// Check 4: Heuristic - scan for React 17 specific patterns in custom code
const customCodePatterns = await scanForLegacyPatterns(projectPath);
if (customCodePatterns.found) {
indicators.push(...customCodePatterns.patterns);
return { version: 'react17', confidence: 'medium', indicators };
}
// Check 5: If project was created before OpenNoodl fork, assume React 17
const projectCreated = projectJson.createdAt || await getProjectCreationDate(projectPath);
if (projectCreated && new Date(projectCreated) < new Date('2024-01-01')) {
indicators.push('Project created before OpenNoodl fork');
return { version: 'react17', confidence: 'medium', indicators };
}
// Default: Assume React 19 for truly unknown projects
return {
version: 'unknown',
confidence: 'low',
indicators: ['No version indicators found']
};
}
```
### Legacy Pattern Scanner
```typescript
// Quick scan for legacy React patterns in JavaScript files
interface LegacyPatternScan {
found: boolean;
patterns: string[];
files: Array<{ path: string; line: number; pattern: string; }>;
}
async function scanForLegacyPatterns(projectPath: string): Promise<LegacyPatternScan> {
const jsFiles = await glob(`${projectPath}/**/*.{js,jsx,ts,tsx}`, {
ignore: ['**/node_modules/**']
});
const legacyPatterns = [
{ regex: /componentWillMount\s*\(/, name: 'componentWillMount' },
{ regex: /componentWillReceiveProps\s*\(/, name: 'componentWillReceiveProps' },
{ regex: /componentWillUpdate\s*\(/, name: 'componentWillUpdate' },
{ regex: /UNSAFE_componentWillMount/, name: 'UNSAFE_componentWillMount' },
{ regex: /UNSAFE_componentWillReceiveProps/, name: 'UNSAFE_componentWillReceiveProps' },
{ regex: /UNSAFE_componentWillUpdate/, name: 'UNSAFE_componentWillUpdate' },
{ regex: /ref\s*=\s*["'][^"']+["']/, name: 'String ref' },
{ regex: /contextTypes\s*=/, name: 'Legacy contextTypes' },
{ regex: /childContextTypes\s*=/, name: 'Legacy childContextTypes' },
{ regex: /getChildContext\s*\(/, name: 'getChildContext' },
{ regex: /React\.createFactory/, name: 'createFactory' },
];
const results: LegacyPatternScan = {
found: false,
patterns: [],
files: []
};
for (const file of jsFiles) {
const content = await fs.readFile(file, 'utf-8');
const lines = content.split('\n');
for (const pattern of legacyPatterns) {
lines.forEach((line, index) => {
if (pattern.regex.test(line)) {
results.found = true;
if (!results.patterns.includes(pattern.name)) {
results.patterns.push(pattern.name);
}
results.files.push({
path: file,
line: index + 1,
pattern: pattern.name
});
}
});
}
}
return results;
}
```
## Visual Indicators
### Projects Panel - Recent Projects List
```tsx
// packages/noodl-editor/src/editor/src/views/ProjectsPanel/ProjectCard.tsx
interface ProjectCardProps {
project: RecentProject;
runtimeInfo: RuntimeVersionInfo;
}
function ProjectCard({ project, runtimeInfo }: ProjectCardProps) {
const isLegacy = runtimeInfo.version === 'react17';
const [expanded, setExpanded] = useState(false);
return (
<div className={css['project-card', isLegacy && 'project-card--legacy']}>
<div className={css['project-card__header']}>
<FolderIcon />
<div className={css['project-card__info']}>
<h3 className={css['project-card__name']}>
{project.name}
{isLegacy && (
<Tooltip content="This project uses React 17 and needs migration">
<WarningIcon className={css['project-card__warning-icon']} />
</Tooltip>
)}
</h3>
<span className={css['project-card__date']}>
Last opened: {formatDate(project.lastOpened)}
</span>
</div>
</div>
{isLegacy && (
<div className={css['project-card__legacy-banner']}>
<div className={css['legacy-banner__content']}>
<WarningIcon size={16} />
<span>Legacy Runtime (React 17)</span>
</div>
<button
className={css['legacy-banner__expand']}
onClick={() => setExpanded(!expanded)}
>
{expanded ? 'Less' : 'More'}
</button>
</div>
)}
{isLegacy && expanded && (
<div className={css['project-card__legacy-details']}>
<p>
This project needs migration to work with OpenNoodl 1.2+.
Your original project will remain untouched.
</p>
<div className={css['legacy-details__actions']}>
<Button
variant="primary"
onClick={() => openMigrationWizard(project)}
>
Migrate Project
</Button>
<Button
variant="secondary"
onClick={() => openProjectReadOnly(project)}
>
Open Read-Only
</Button>
<Button
variant="ghost"
onClick={() => openDocs('migration-guide')}
>
Learn More
</Button>
</div>
</div>
)}
{!isLegacy && (
<div className={css['project-card__actions']}>
<Button onClick={() => openProject(project)}>Open</Button>
</div>
)}
</div>
);
}
```
### CSS Styles
```scss
// packages/noodl-editor/src/editor/src/styles/projects-panel.scss
.project-card {
background: var(--color-bg-secondary);
border-radius: 8px;
padding: 16px;
border: 1px solid var(--color-border);
transition: border-color 0.2s;
&:hover {
border-color: var(--color-border-hover);
}
&--legacy {
border-color: var(--color-warning-border);
&:hover {
border-color: var(--color-warning-border-hover);
}
}
}
.project-card__warning-icon {
color: var(--color-warning);
margin-left: 8px;
width: 16px;
height: 16px;
}
.project-card__legacy-banner {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--color-warning-bg);
border-radius: 4px;
padding: 8px 12px;
margin-top: 12px;
}
.legacy-banner__content {
display: flex;
align-items: center;
gap: 8px;
color: var(--color-warning-text);
font-size: 13px;
font-weight: 500;
}
.project-card__legacy-details {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--color-border);
p {
color: var(--color-text-secondary);
font-size: 13px;
margin-bottom: 12px;
}
}
.legacy-details__actions {
display: flex;
gap: 8px;
}
```
### Open Project Dialog - Legacy Detection
```tsx
// packages/noodl-editor/src/editor/src/views/dialogs/OpenProjectDialog.tsx
function OpenProjectDialog({ onClose }: { onClose: () => void }) {
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [runtimeInfo, setRuntimeInfo] = useState<RuntimeVersionInfo | null>(null);
const [checking, setChecking] = useState(false);
const handleFolderSelect = async (path: string) => {
setSelectedPath(path);
setChecking(true);
try {
const info = await detectRuntimeVersion(path);
setRuntimeInfo(info);
} finally {
setChecking(false);
}
};
const isLegacy = runtimeInfo?.version === 'react17';
return (
<Dialog title="Open Project" onClose={onClose}>
<FolderPicker
value={selectedPath}
onChange={handleFolderSelect}
/>
{checking && (
<div className={css['checking-indicator']}>
<Spinner size={16} />
<span>Checking project version...</span>
</div>
)}
{runtimeInfo && isLegacy && (
<LegacyProjectNotice
projectPath={selectedPath}
runtimeInfo={runtimeInfo}
/>
)}
<DialogActions>
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
{isLegacy ? (
<>
<Button
variant="secondary"
onClick={() => openProjectReadOnly(selectedPath)}
>
Open Read-Only
</Button>
<Button
variant="primary"
onClick={() => openMigrationWizard(selectedPath)}
>
Migrate & Open
</Button>
</>
) : (
<Button
variant="primary"
disabled={!selectedPath || checking}
onClick={() => openProject(selectedPath)}
>
Open
</Button>
)}
</DialogActions>
</Dialog>
);
}
function LegacyProjectNotice({
projectPath,
runtimeInfo
}: {
projectPath: string;
runtimeInfo: RuntimeVersionInfo;
}) {
const projectName = path.basename(projectPath);
const defaultTargetPath = `${projectPath}-r19`;
const [targetPath, setTargetPath] = useState(defaultTargetPath);
return (
<div className={css['legacy-notice']}>
<div className={css['legacy-notice__header']}>
<WarningIcon size={20} />
<h3>Legacy Project Detected</h3>
</div>
<p>
<strong>"{projectName}"</strong> was created with an older version of
Noodl using React 17. OpenNoodl 1.2+ uses React 19.
</p>
<p>
To open this project, we'll create a migrated copy.
Your original project will remain untouched.
</p>
<div className={css['legacy-notice__paths']}>
<div className={css['path-row']}>
<label>Original:</label>
<code>{projectPath}</code>
</div>
<div className={css['path-row']}>
<label>Copy:</label>
<input
type="text"
value={targetPath}
onChange={(e) => setTargetPath(e.target.value)}
/>
<Button
variant="ghost"
size="small"
onClick={() => selectFolder().then(setTargetPath)}
>
Change...
</Button>
</div>
</div>
{runtimeInfo.confidence !== 'high' && (
<div className={css['legacy-notice__confidence']}>
<InfoIcon size={14} />
<span>
Detection confidence: {runtimeInfo.confidence}.
Indicators: {runtimeInfo.indicators.join(', ')}
</span>
</div>
)}
</div>
);
}
```
## Read-Only Mode
When opening a legacy project in read-only mode:
```typescript
// packages/noodl-editor/src/editor/src/models/projectmodel.ts
interface ProjectOpenOptions {
readOnly?: boolean;
legacyMode?: boolean;
}
async function openProject(path: string, options: ProjectOpenOptions = {}) {
const project = await ProjectModel.fromDirectory(path);
if (options.readOnly || options.legacyMode) {
project.setReadOnly(true);
// Show banner in editor
EditorBanner.show({
type: 'warning',
message: 'This project is open in read-only mode. Migrate to make changes.',
actions: [
{ label: 'Migrate Now', onClick: () => openMigrationWizard(path) },
{ label: 'Dismiss', onClick: () => EditorBanner.hide() }
]
});
}
return project;
}
```
### Read-Only Banner Component
```tsx
// packages/noodl-editor/src/editor/src/views/EditorBanner.tsx
interface EditorBannerProps {
type: 'info' | 'warning' | 'error';
message: string;
actions?: Array<{
label: string;
onClick: () => void;
}>;
}
function EditorBanner({ type, message, actions }: EditorBannerProps) {
return (
<div className={css['editor-banner', `editor-banner--${type}`]}>
<div className={css['editor-banner__content']}>
{type === 'warning' && <WarningIcon size={16} />}
{type === 'info' && <InfoIcon size={16} />}
{type === 'error' && <ErrorIcon size={16} />}
<span>{message}</span>
</div>
{actions && (
<div className={css['editor-banner__actions']}>
{actions.map((action, i) => (
<Button
key={i}
variant={i === 0 ? 'primary' : 'ghost'}
size="small"
onClick={action.onClick}
>
{action.label}
</Button>
))}
</div>
)}
</div>
);
}
```
## Testing Checklist
- [ ] Legacy project shows warning icon in recent projects
- [ ] Clicking legacy project shows expanded details
- [ ] "Migrate Project" button opens migration wizard
- [ ] "Open Read-Only" opens project without changes
- [ ] Opening folder with legacy project shows detection dialog
- [ ] Target path can be customized
- [ ] Read-only mode shows banner
- [ ] Banner "Migrate Now" opens wizard
- [ ] New/modern projects open normally without warnings

View File

@@ -0,0 +1,994 @@
# 02 - Migration Wizard
## Overview
A step-by-step wizard that guides users through the migration process. The wizard handles project copying, scanning, reporting, and executing migrations.
## Wizard Steps
1. **Confirm** - Confirm source/target paths
2. **Scan** - Analyze project for migration needs
3. **Report** - Show what needs to change
4. **Configure** - (Optional) Set up AI assistance
5. **Migrate** - Execute the migration
6. **Complete** - Summary and next steps
## State Machine
```typescript
// packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts
type MigrationStep =
| 'confirm'
| 'scanning'
| 'report'
| 'configureAi'
| 'migrating'
| 'complete'
| 'failed';
interface MigrationSession {
id: string;
step: MigrationStep;
// Source project
source: {
path: string;
name: string;
runtimeVersion: 'react17';
};
// Target (copy) project
target: {
path: string;
copied: boolean;
};
// Scan results
scan?: {
completedAt: string;
totalComponents: number;
totalNodes: number;
customJsFiles: number;
categories: {
automatic: ComponentMigrationInfo[];
simpleFixes: ComponentMigrationInfo[];
needsReview: ComponentMigrationInfo[];
};
};
// AI configuration
ai?: {
enabled: boolean;
apiKey?: string; // Only stored in memory during session
budget: {
max: number;
spent: number;
pauseIncrement: number;
};
};
// Migration progress
progress?: {
phase: 'copying' | 'automatic' | 'ai-assisted' | 'finalizing';
current: number;
total: number;
currentComponent?: string;
log: MigrationLogEntry[];
};
// Final result
result?: {
success: boolean;
migrated: number;
needsReview: number;
failed: number;
totalCost: number;
duration: number;
};
}
interface ComponentMigrationInfo {
id: string;
name: string;
path: string;
issues: MigrationIssue[];
estimatedCost?: number;
}
interface MigrationIssue {
id: string;
type: MigrationIssueType;
description: string;
location: {
file: string;
line: number;
column?: number;
};
autoFixable: boolean;
fix?: {
type: 'automatic' | 'ai-required';
description: string;
};
}
type MigrationIssueType =
| 'componentWillMount'
| 'componentWillReceiveProps'
| 'componentWillUpdate'
| 'unsafeLifecycle'
| 'stringRef'
| 'legacyContext'
| 'createFactory'
| 'findDOMNode'
| 'reactDomRender'
| 'other';
interface MigrationLogEntry {
timestamp: string;
level: 'info' | 'success' | 'warning' | 'error';
component?: string;
message: string;
details?: string;
cost?: number;
}
```
## Step 1: Confirm
```tsx
// packages/noodl-editor/src/editor/src/views/migration/steps/ConfirmStep.tsx
interface ConfirmStepProps {
session: MigrationSession;
onUpdateTarget: (path: string) => void;
onNext: () => void;
onCancel: () => void;
}
function ConfirmStep({ session, onUpdateTarget, onNext, onCancel }: ConfirmStepProps) {
const [targetPath, setTargetPath] = useState(session.target.path);
const [targetExists, setTargetExists] = useState(false);
useEffect(() => {
checkPathExists(targetPath).then(setTargetExists);
}, [targetPath]);
const handleTargetChange = (newPath: string) => {
setTargetPath(newPath);
onUpdateTarget(newPath);
};
return (
<WizardStep
title="Migrate Project"
subtitle="We'll create a copy of your project and migrate it to React 19"
>
<div className={css['confirm-step']}>
<PathSection
label="Original Project (will not be modified)"
path={session.source.path}
icon={<LockIcon />}
readonly
/>
<div className={css['arrow-down']}>
<ArrowDownIcon />
<span>Creates copy</span>
</div>
<PathSection
label="Migrated Copy"
path={targetPath}
onChange={handleTargetChange}
error={targetExists ? 'A folder already exists at this location' : undefined}
icon={<FolderPlusIcon />}
/>
{targetExists && (
<div className={css['path-exists-options']}>
<Button
variant="secondary"
size="small"
onClick={() => handleTargetChange(`${targetPath}-${Date.now()}`)}
>
Use Different Name
</Button>
<Button
variant="ghost"
size="small"
onClick={() => confirmOverwrite()}
>
Overwrite Existing
</Button>
</div>
)}
<InfoBox type="info">
<p>
<strong>What happens next:</strong>
</p>
<ol>
<li>Your project will be copied to the new location</li>
<li>We'll scan for compatibility issues</li>
<li>You'll see a report of what needs to change</li>
<li>Optionally, AI can help fix complex code</li>
</ol>
</InfoBox>
</div>
<WizardActions>
<Button variant="secondary" onClick={onCancel}>
Cancel
</Button>
<Button
variant="primary"
onClick={onNext}
disabled={targetExists}
>
Start Migration
</Button>
</WizardActions>
</WizardStep>
);
}
```
## Step 2: Scanning
```tsx
// packages/noodl-editor/src/editor/src/views/migration/steps/ScanningStep.tsx
interface ScanningStepProps {
session: MigrationSession;
onComplete: (scan: MigrationScan) => void;
onError: (error: Error) => void;
}
function ScanningStep({ session, onComplete, onError }: ScanningStepProps) {
const [phase, setPhase] = useState<'copying' | 'scanning'>('copying');
const [progress, setProgress] = useState(0);
const [currentItem, setCurrentItem] = useState('');
const [stats, setStats] = useState({ components: 0, nodes: 0, jsFiles: 0 });
useEffect(() => {
runScan();
}, []);
const runScan = async () => {
try {
// Phase 1: Copy project
setPhase('copying');
await copyProject(session.source.path, session.target.path, {
onProgress: (p, item) => {
setProgress(p * 50); // 0-50%
setCurrentItem(item);
}
});
// Phase 2: Scan for issues
setPhase('scanning');
const scan = await scanProject(session.target.path, {
onProgress: (p, item, partialStats) => {
setProgress(50 + p * 50); // 50-100%
setCurrentItem(item);
setStats(partialStats);
}
});
onComplete(scan);
} catch (error) {
onError(error);
}
};
return (
<WizardStep
title={phase === 'copying' ? 'Copying Project...' : 'Analyzing Project...'}
subtitle={phase === 'copying'
? 'Creating a safe copy before making any changes'
: 'Scanning components for compatibility issues'
}
>
<div className={css['scanning-step']}>
<ProgressBar value={progress} max={100} />
<div className={css['scanning-current']}>
{currentItem && (
<>
<Spinner size={14} />
<span>{currentItem}</span>
</>
)}
</div>
<div className={css['scanning-stats']}>
<StatBox label="Components" value={stats.components} />
<StatBox label="Nodes" value={stats.nodes} />
<StatBox label="JS Files" value={stats.jsFiles} />
</div>
{phase === 'scanning' && (
<div className={css['scanning-note']}>
<InfoIcon size={14} />
<span>
Looking for React 17 patterns that need updating...
</span>
</div>
)}
</div>
</WizardStep>
);
}
```
## Step 3: Report
```tsx
// packages/noodl-editor/src/editor/src/views/migration/steps/ReportStep.tsx
interface ReportStepProps {
session: MigrationSession;
onConfigureAi: () => void;
onMigrateWithoutAi: () => void;
onMigrateWithAi: () => void;
onCancel: () => void;
}
function ReportStep({
session,
onConfigureAi,
onMigrateWithoutAi,
onMigrateWithAi,
onCancel
}: ReportStepProps) {
const { scan } = session;
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
const totalIssues =
scan.categories.simpleFixes.length +
scan.categories.needsReview.length;
const estimatedCost = scan.categories.simpleFixes
.concat(scan.categories.needsReview)
.reduce((sum, c) => sum + (c.estimatedCost || 0), 0);
const allAutomatic = totalIssues === 0;
return (
<WizardStep
title="Migration Report"
subtitle={`${scan.totalComponents} components analyzed`}
>
<div className={css['report-step']}>
{/* Summary Stats */}
<div className={css['report-summary']}>
<StatCard
icon={<CheckCircleIcon />}
value={scan.categories.automatic.length}
label="Automatic"
variant="success"
/>
<StatCard
icon={<ZapIcon />}
value={scan.categories.simpleFixes.length}
label="Simple Fixes"
variant="info"
/>
<StatCard
icon={<ToolIcon />}
value={scan.categories.needsReview.length}
label="Needs Review"
variant="warning"
/>
</div>
{/* Category Details */}
<div className={css['report-categories']}>
<CategorySection
title="Automatic"
description="These will migrate without any changes"
icon={<CheckCircleIcon />}
items={scan.categories.automatic}
variant="success"
expanded={expandedCategory === 'automatic'}
onToggle={() => setExpandedCategory(
expandedCategory === 'automatic' ? null : 'automatic'
)}
/>
{scan.categories.simpleFixes.length > 0 && (
<CategorySection
title="Simple Fixes"
description="Minor syntax updates needed"
icon={<ZapIcon />}
items={scan.categories.simpleFixes}
variant="info"
expanded={expandedCategory === 'simpleFixes'}
onToggle={() => setExpandedCategory(
expandedCategory === 'simpleFixes' ? null : 'simpleFixes'
)}
showIssueDetails
/>
)}
{scan.categories.needsReview.length > 0 && (
<CategorySection
title="Needs Review"
description="May require manual adjustment"
icon={<ToolIcon />}
items={scan.categories.needsReview}
variant="warning"
expanded={expandedCategory === 'needsReview'}
onToggle={() => setExpandedCategory(
expandedCategory === 'needsReview' ? null : 'needsReview'
)}
showIssueDetails
/>
)}
</div>
{/* AI Assistance Prompt */}
{!allAutomatic && (
<div className={css['ai-prompt']}>
<div className={css['ai-prompt__icon']}>
<RobotIcon size={24} />
</div>
<div className={css['ai-prompt__content']}>
<h4>AI-Assisted Migration Available</h4>
<p>
Claude can automatically fix the {totalIssues} components that
need code changes. Estimated cost: ~${estimatedCost.toFixed(2)}
</p>
</div>
<Button
variant="secondary"
onClick={onConfigureAi}
>
Configure AI Assistant
</Button>
</div>
)}
</div>
<WizardActions>
<Button variant="secondary" onClick={onCancel}>
Cancel
</Button>
{allAutomatic ? (
<Button variant="primary" onClick={onMigrateWithoutAi}>
Migrate Project
</Button>
) : (
<>
<Button variant="secondary" onClick={onMigrateWithoutAi}>
Migrate Without AI
</Button>
{session.ai?.enabled && (
<Button variant="primary" onClick={onMigrateWithAi}>
Migrate With AI
</Button>
)}
</>
)}
</WizardActions>
</WizardStep>
);
}
// Category Section Component
function CategorySection({
title,
description,
icon,
items,
variant,
expanded,
onToggle,
showIssueDetails = false
}: CategorySectionProps) {
return (
<div className={css['category-section', `category-section--${variant}`]}>
<button
className={css['category-header']}
onClick={onToggle}
>
<div className={css['category-header__left']}>
{icon}
<div>
<h4>{title} ({items.length})</h4>
<p>{description}</p>
</div>
</div>
<ChevronIcon direction={expanded ? 'up' : 'down'} />
</button>
{expanded && (
<div className={css['category-items']}>
{items.map(item => (
<div key={item.id} className={css['category-item']}>
<ComponentIcon />
<div className={css['category-item__info']}>
<span className={css['category-item__name']}>
{item.name}
</span>
{showIssueDetails && item.issues.length > 0 && (
<ul className={css['category-item__issues']}>
{item.issues.map(issue => (
<li key={issue.id}>
<code>{issue.type}</code>
<span>{issue.description}</span>
</li>
))}
</ul>
)}
</div>
{item.estimatedCost && (
<span className={css['category-item__cost']}>
~${item.estimatedCost.toFixed(2)}
</span>
)}
</div>
))}
</div>
)}
</div>
);
}
```
## Step 4: Migration Progress
```tsx
// packages/noodl-editor/src/editor/src/views/migration/steps/MigratingStep.tsx
interface MigratingStepProps {
session: MigrationSession;
useAi: boolean;
onPause: () => void;
onAiDecision: (decision: AiDecision) => void;
onComplete: (result: MigrationResult) => void;
onError: (error: Error) => void;
}
interface AiDecision {
componentId: string;
action: 'retry' | 'skip' | 'manual' | 'getHelp';
}
function MigratingStep({
session,
useAi,
onPause,
onAiDecision,
onComplete,
onError
}: MigratingStepProps) {
const [awaitingDecision, setAwaitingDecision] = useState<AiDecisionRequest | null>(null);
const { progress, ai } = session;
const budgetPercent = ai ? (ai.budget.spent / ai.budget.max) * 100 : 0;
return (
<WizardStep
title={useAi ? 'AI Migration in Progress' : 'Migrating Project...'}
subtitle={`Phase: ${progress?.phase || 'Starting'}`}
>
<div className={css['migrating-step']}>
{/* Budget Display (if using AI) */}
{useAi && ai && (
<div className={css['budget-display']}>
<div className={css['budget-display__header']}>
<span>Budget</span>
<span>${ai.budget.spent.toFixed(2)} / ${ai.budget.max.toFixed(2)}</span>
</div>
<ProgressBar
value={budgetPercent}
max={100}
variant={budgetPercent > 80 ? 'warning' : 'default'}
/>
</div>
)}
{/* Component Progress */}
<div className={css['component-progress']}>
{progress?.log.slice(-5).map((entry, i) => (
<LogEntry key={i} entry={entry} />
))}
{progress?.currentComponent && !awaitingDecision && (
<div className={css['current-component']}>
<Spinner size={16} />
<span>{progress.currentComponent}</span>
{useAi && <span className={css['estimate']}>~$0.08</span>}
</div>
)}
</div>
{/* AI Decision Required */}
{awaitingDecision && (
<AiDecisionPanel
request={awaitingDecision}
budget={ai?.budget}
onDecision={(decision) => {
setAwaitingDecision(null);
onAiDecision(decision);
}}
/>
)}
{/* Overall Progress */}
<div className={css['overall-progress']}>
<ProgressBar
value={progress?.current || 0}
max={progress?.total || 100}
/>
<span>
{progress?.current || 0} / {progress?.total || 0} components
</span>
</div>
</div>
<WizardActions>
<Button
variant="secondary"
onClick={onPause}
disabled={!!awaitingDecision}
>
Pause Migration
</Button>
</WizardActions>
</WizardStep>
);
}
// Log Entry Component
function LogEntry({ entry }: { entry: MigrationLogEntry }) {
const icons = {
info: <InfoIcon size={14} />,
success: <CheckIcon size={14} />,
warning: <WarningIcon size={14} />,
error: <ErrorIcon size={14} />
};
return (
<div className={css['log-entry', `log-entry--${entry.level}`]}>
{icons[entry.level]}
<div className={css['log-entry__content']}>
{entry.component && (
<span className={css['log-entry__component']}>
{entry.component}
</span>
)}
<span className={css['log-entry__message']}>
{entry.message}
</span>
</div>
{entry.cost && (
<span className={css['log-entry__cost']}>
${entry.cost.toFixed(2)}
</span>
)}
</div>
);
}
// AI Decision Panel
function AiDecisionPanel({
request,
budget,
onDecision
}: {
request: AiDecisionRequest;
budget: MigrationBudget;
onDecision: (decision: AiDecision) => void;
}) {
return (
<div className={css['decision-panel']}>
<div className={css['decision-panel__header']}>
<ToolIcon size={20} />
<h4>{request.componentName} - Needs Your Input</h4>
</div>
<p>
Claude attempted {request.attempts} migrations but the component
still has issues. Here's what happened:
</p>
<div className={css['decision-panel__attempts']}>
{request.attemptHistory.map((attempt, i) => (
<div key={i} className={css['attempt-entry']}>
<span>Attempt {i + 1}:</span>
<span>{attempt.description}</span>
</div>
))}
</div>
<div className={css['decision-panel__cost']}>
Cost so far: ${request.costSpent.toFixed(2)}
</div>
<div className={css['decision-panel__options']}>
<Button
onClick={() => onDecision({
componentId: request.componentId,
action: 'retry'
})}
>
Try Again (~${request.retryCost.toFixed(2)})
</Button>
<Button
variant="secondary"
onClick={() => onDecision({
componentId: request.componentId,
action: 'skip'
})}
>
Skip Component
</Button>
<Button
variant="secondary"
onClick={() => onDecision({
componentId: request.componentId,
action: 'getHelp'
})}
>
Get Suggestions (~$0.02)
</Button>
</div>
</div>
);
}
```
## Step 5: Complete
```tsx
// packages/noodl-editor/src/editor/src/views/migration/steps/CompleteStep.tsx
interface CompleteStepProps {
session: MigrationSession;
onViewLog: () => void;
onOpenProject: () => void;
}
function CompleteStep({ session, onViewLog, onOpenProject }: CompleteStepProps) {
const { result, source, target } = session;
const hasIssues = result.needsReview > 0;
return (
<WizardStep
title={hasIssues ? 'Migration Complete (With Notes)' : 'Migration Complete!'}
icon={hasIssues ? <CheckWarningIcon /> : <CheckCircleIcon />}
>
<div className={css['complete-step']}>
{/* Summary */}
<div className={css['complete-summary']}>
<div className={css['summary-stats']}>
<StatCard
icon={<CheckIcon />}
value={result.migrated}
label="Migrated"
variant="success"
/>
{result.needsReview > 0 && (
<StatCard
icon={<WarningIcon />}
value={result.needsReview}
label="Needs Review"
variant="warning"
/>
)}
{result.failed > 0 && (
<StatCard
icon={<ErrorIcon />}
value={result.failed}
label="Failed"
variant="error"
/>
)}
</div>
{session.ai?.enabled && (
<div className={css['summary-cost']}>
<RobotIcon size={16} />
<span>AI cost: ${result.totalCost.toFixed(2)}</span>
</div>
)}
<div className={css['summary-time']}>
<ClockIcon size={16} />
<span>Time: {formatDuration(result.duration)}</span>
</div>
</div>
{/* Project Paths */}
<div className={css['complete-paths']}>
<h4>Project Locations</h4>
<PathDisplay
label="Original (untouched)"
path={source.path}
icon={<LockIcon />}
/>
<PathDisplay
label="Migrated copy"
path={target.path}
icon={<FolderIcon />}
actions={[
{ label: 'Show in Finder', onClick: () => showInFinder(target.path) }
]}
/>
</div>
{/* What's Next */}
<div className={css['complete-next']}>
<h4>What's Next?</h4>
<ol>
{result.needsReview > 0 && (
<li>
<WarningIcon size={14} />
Components marked with have notes in the component panel -
click to see migration details
</li>
)}
<li>
<TestIcon size={14} />
Test your app thoroughly before deploying
</li>
<li>
<TrashIcon size={14} />
Once confirmed working, you can archive or delete the original folder
</li>
</ol>
</div>
</div>
<WizardActions>
<Button variant="secondary" onClick={onViewLog}>
View Migration Log
</Button>
<Button variant="primary" onClick={onOpenProject}>
Open Migrated Project
</Button>
</WizardActions>
</WizardStep>
);
}
```
## Wizard Container
```tsx
// packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.tsx
interface MigrationWizardProps {
sourcePath: string;
onComplete: (targetPath: string) => void;
onCancel: () => void;
}
function MigrationWizard({ sourcePath, onComplete, onCancel }: MigrationWizardProps) {
const [session, dispatch] = useReducer(migrationReducer, {
id: generateId(),
step: 'confirm',
source: {
path: sourcePath,
name: path.basename(sourcePath),
runtimeVersion: 'react17'
},
target: {
path: `${sourcePath}-r19`,
copied: false
}
});
const renderStep = () => {
switch (session.step) {
case 'confirm':
return (
<ConfirmStep
session={session}
onUpdateTarget={(path) => dispatch({ type: 'SET_TARGET_PATH', path })}
onNext={() => dispatch({ type: 'START_SCAN' })}
onCancel={onCancel}
/>
);
case 'scanning':
return (
<ScanningStep
session={session}
onComplete={(scan) => dispatch({ type: 'SCAN_COMPLETE', scan })}
onError={(error) => dispatch({ type: 'ERROR', error })}
/>
);
case 'report':
return (
<ReportStep
session={session}
onConfigureAi={() => dispatch({ type: 'CONFIGURE_AI' })}
onMigrateWithoutAi={() => dispatch({ type: 'START_MIGRATE', useAi: false })}
onMigrateWithAi={() => dispatch({ type: 'START_MIGRATE', useAi: true })}
onCancel={onCancel}
/>
);
case 'configureAi':
return (
<AiConfigStep
session={session}
onSave={(config) => dispatch({ type: 'SAVE_AI_CONFIG', config })}
onBack={() => dispatch({ type: 'BACK_TO_REPORT' })}
/>
);
case 'migrating':
return (
<MigratingStep
session={session}
useAi={session.ai?.enabled ?? false}
onPause={() => dispatch({ type: 'PAUSE' })}
onAiDecision={(d) => dispatch({ type: 'AI_DECISION', decision: d })}
onComplete={(result) => dispatch({ type: 'COMPLETE', result })}
onError={(error) => dispatch({ type: 'ERROR', error })}
/>
);
case 'complete':
return (
<CompleteStep
session={session}
onViewLog={() => openMigrationLog(session)}
onOpenProject={() => onComplete(session.target.path)}
/>
);
case 'failed':
return (
<FailedStep
session={session}
onRetry={() => dispatch({ type: 'RETRY' })}
onCancel={onCancel}
/>
);
}
};
return (
<Dialog
className={css['migration-wizard']}
size="large"
onClose={onCancel}
>
<WizardProgress
steps={['Confirm', 'Scan', 'Report', 'Migrate', 'Complete']}
currentStep={stepToIndex(session.step)}
/>
{renderStep()}
</Dialog>
);
}
```
## Testing Checklist
- [ ] Wizard opens from project detection
- [ ] Target path can be customized
- [ ] Duplicate path detection works
- [ ] Scanning shows progress
- [ ] Report categorizes components correctly
- [ ] AI config button appears when needed
- [ ] Migration progress updates in real-time
- [ ] AI decision panel appears on failure
- [ ] Complete screen shows correct stats
- [ ] "Open Project" launches migrated project
- [ ] Cancel works at every step
- [ ] Errors are handled gracefully

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,793 @@
# 04 - Post-Migration Editor Experience
## Overview
After migration, the editor needs to clearly communicate which components were successfully migrated, which need review, and provide easy access to migration notes and AI suggestions.
## Component Panel Indicators
### Visual Status Badges
```tsx
// packages/noodl-editor/src/editor/src/views/panels/ComponentsPanel/ComponentItem.tsx
interface ComponentItemProps {
component: ComponentModel;
migrationNote?: ComponentMigrationNote;
onClick: () => void;
onContextMenu: (e: React.MouseEvent) => void;
}
function ComponentItem({
component,
migrationNote,
onClick,
onContextMenu
}: ComponentItemProps) {
const status = migrationNote?.status;
const statusConfig = {
'auto': null, // No badge for auto-migrated
'ai-migrated': {
icon: <SparklesIcon size={12} />,
tooltip: 'AI migrated - click to see changes',
className: 'status-ai'
},
'needs-review': {
icon: <WarningIcon size={12} />,
tooltip: 'Needs manual review',
className: 'status-warning'
},
'manually-fixed': {
icon: <CheckIcon size={12} />,
tooltip: 'Manually fixed',
className: 'status-success'
}
};
const badge = status ? statusConfig[status] : null;
return (
<div
className={css['component-item', badge?.className]}
onClick={onClick}
onContextMenu={onContextMenu}
>
<ComponentIcon type={getComponentIconType(component)} />
<span className={css['component-item__name']}>
{component.localName}
</span>
{badge && (
<Tooltip content={badge.tooltip}>
<span className={css['component-item__badge']}>
{badge.icon}
</span>
</Tooltip>
)}
</div>
);
}
```
### CSS for Status Indicators
```scss
// packages/noodl-editor/src/editor/src/styles/components-panel.scss
.component-item {
display: flex;
align-items: center;
padding: 6px 12px;
cursor: pointer;
border-radius: 4px;
gap: 8px;
&:hover {
background: var(--color-bg-hover);
}
&.status-warning {
.component-item__badge {
color: var(--color-warning);
}
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--color-warning);
border-radius: 2px 0 0 2px;
}
}
&.status-ai {
.component-item__badge {
color: var(--color-info);
}
}
&.status-success {
.component-item__badge {
color: var(--color-success);
}
}
}
.component-item__badge {
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
svg {
width: 12px;
height: 12px;
}
}
```
## Migration Notes Panel
### Accessing Migration Notes
When a user clicks on a component with a migration status, show a panel with details:
```tsx
// packages/noodl-editor/src/editor/src/views/panels/MigrationNotesPanel.tsx
interface MigrationNotesPanelProps {
component: ComponentModel;
note: ComponentMigrationNote;
onDismiss: () => void;
onViewOriginal: () => void;
onViewMigrated: () => void;
}
function MigrationNotesPanel({
component,
note,
onDismiss,
onViewOriginal,
onViewMigrated
}: MigrationNotesPanelProps) {
const statusLabels = {
'auto': 'Automatically Migrated',
'ai-migrated': 'AI Migrated',
'needs-review': 'Needs Manual Review',
'manually-fixed': 'Manually Fixed'
};
const statusIcons = {
'auto': <CheckCircleIcon />,
'ai-migrated': <SparklesIcon />,
'needs-review': <WarningIcon />,
'manually-fixed': <CheckIcon />
};
return (
<Panel
title="Migration Notes"
icon={statusIcons[note.status]}
onClose={onDismiss}
>
<div className={css['migration-notes']}>
{/* Status Header */}
<div className={css['notes-status', `notes-status--${note.status}`]}>
{statusIcons[note.status]}
<span>{statusLabels[note.status]}</span>
</div>
{/* Component Name */}
<div className={css['notes-component']}>
<ComponentIcon type={getComponentIconType(component)} />
<span>{component.name}</span>
</div>
{/* Issues List */}
{note.issues && note.issues.length > 0 && (
<div className={css['notes-section']}>
<h4>Issues Detected</h4>
<ul className={css['notes-issues']}>
{note.issues.map((issue, i) => (
<li key={i}>
<code>{issue.type || 'Issue'}</code>
<span>{issue}</span>
</li>
))}
</ul>
</div>
)}
{/* AI Suggestion */}
{note.aiSuggestion && (
<div className={css['notes-section']}>
<h4>
<RobotIcon size={14} />
Claude's Suggestion
</h4>
<div className={css['notes-suggestion']}>
<ReactMarkdown>
{note.aiSuggestion}
</ReactMarkdown>
</div>
</div>
)}
{/* Actions */}
<div className={css['notes-actions']}>
{note.status === 'needs-review' && (
<>
<Button
variant="secondary"
size="small"
onClick={onViewOriginal}
>
View Original Code
</Button>
<Button
variant="secondary"
size="small"
onClick={onViewMigrated}
>
View Migrated Code
</Button>
</>
)}
<Button
variant="ghost"
size="small"
onClick={onDismiss}
>
Dismiss Warning
</Button>
</div>
{/* Help Link */}
<div className={css['notes-help']}>
<a
href="https://docs.opennoodl.com/migration/react19"
target="_blank"
>
Learn more about React 19 migration
</a>
</div>
</div>
</Panel>
);
}
```
## Migration Summary in Project Info
### Project Info Panel Addition
```tsx
// packages/noodl-editor/src/editor/src/views/panels/ProjectInfoPanel.tsx
function ProjectInfoPanel({ project }: { project: ProjectModel }) {
const migrationInfo = project.migratedFrom;
const migrationNotes = project.migrationNotes;
const notesCounts = migrationNotes ? {
total: Object.keys(migrationNotes).length,
needsReview: Object.values(migrationNotes)
.filter(n => n.status === 'needs-review').length,
aiMigrated: Object.values(migrationNotes)
.filter(n => n.status === 'ai-migrated').length
} : null;
return (
<Panel title="Project Info">
{/* Existing project info... */}
{migrationInfo && (
<div className={css['project-migration-info']}>
<h4>
<MigrationIcon size={14} />
Migration Info
</h4>
<div className={css['migration-details']}>
<div className={css['detail-row']}>
<span>Migrated from:</span>
<code>React 17</code>
</div>
<div className={css['detail-row']}>
<span>Migration date:</span>
<span>{formatDate(migrationInfo.date)}</span>
</div>
<div className={css['detail-row']}>
<span>Original location:</span>
<code className={css['path-truncate']}>
{migrationInfo.originalPath}
</code>
</div>
{migrationInfo.aiAssisted && (
<div className={css['detail-row']}>
<span>AI assisted:</span>
<span>Yes</span>
</div>
)}
</div>
{notesCounts && notesCounts.needsReview > 0 && (
<div className={css['migration-warnings']}>
<WarningIcon size={14} />
<span>
{notesCounts.needsReview} component{notesCounts.needsReview > 1 ? 's' : ''} need review
</span>
<Button
variant="ghost"
size="small"
onClick={() => filterComponentsByStatus('needs-review')}
>
Show
</Button>
</div>
)}
</div>
)}
</Panel>
);
}
```
## Component Filter for Migration Status
### Filter in Components Panel
```tsx
// packages/noodl-editor/src/editor/src/views/panels/ComponentsPanel/ComponentFilter.tsx
interface ComponentFilterProps {
activeFilter: ComponentFilter;
onFilterChange: (filter: ComponentFilter) => void;
migrationCounts?: {
needsReview: number;
aiMigrated: number;
};
}
type ComponentFilter = 'all' | 'needs-review' | 'ai-migrated' | 'pages' | 'components';
function ComponentFilterBar({
activeFilter,
onFilterChange,
migrationCounts
}: ComponentFilterProps) {
const hasMigrationFilters = migrationCounts &&
(migrationCounts.needsReview > 0 || migrationCounts.aiMigrated > 0);
return (
<div className={css['component-filter-bar']}>
<FilterButton
active={activeFilter === 'all'}
onClick={() => onFilterChange('all')}
>
All
</FilterButton>
<FilterButton
active={activeFilter === 'pages'}
onClick={() => onFilterChange('pages')}
>
Pages
</FilterButton>
<FilterButton
active={activeFilter === 'components'}
onClick={() => onFilterChange('components')}
>
Components
</FilterButton>
{hasMigrationFilters && (
<>
<div className={css['filter-divider']} />
{migrationCounts.needsReview > 0 && (
<FilterButton
active={activeFilter === 'needs-review'}
onClick={() => onFilterChange('needs-review')}
badge={migrationCounts.needsReview}
variant="warning"
>
<WarningIcon size={12} />
Needs Review
</FilterButton>
)}
{migrationCounts.aiMigrated > 0 && (
<FilterButton
active={activeFilter === 'ai-migrated'}
onClick={() => onFilterChange('ai-migrated')}
badge={migrationCounts.aiMigrated}
variant="info"
>
<SparklesIcon size={12} />
AI Migrated
</FilterButton>
)}
</>
)}
</div>
);
}
```
## Dismissing Migration Warnings
### Dismiss Functionality
```typescript
// packages/noodl-editor/src/editor/src/models/migration/MigrationNotes.ts
export function dismissMigrationNote(
project: ProjectModel,
componentId: string
): void {
if (!project.migrationNotes?.[componentId]) {
return;
}
// Mark as dismissed with timestamp
project.migrationNotes[componentId] = {
...project.migrationNotes[componentId],
dismissedAt: new Date().toISOString()
};
// Save project
project.save();
}
export function getMigrationNotesForDisplay(
project: ProjectModel,
showDismissed: boolean = false
): Record<string, ComponentMigrationNote> {
if (!project.migrationNotes) {
return {};
}
if (showDismissed) {
return project.migrationNotes;
}
// Filter out dismissed notes
return Object.fromEntries(
Object.entries(project.migrationNotes)
.filter(([_, note]) => !note.dismissedAt)
);
}
```
### Restore Dismissed Warnings
```tsx
// packages/noodl-editor/src/editor/src/views/panels/ComponentsPanel/DismissedWarnings.tsx
function DismissedWarningsSection({ project }: { project: ProjectModel }) {
const [showDismissed, setShowDismissed] = useState(false);
const dismissedNotes = Object.entries(project.migrationNotes || {})
.filter(([_, note]) => note.dismissedAt);
if (dismissedNotes.length === 0) {
return null;
}
return (
<div className={css['dismissed-warnings']}>
<button
className={css['dismissed-toggle']}
onClick={() => setShowDismissed(!showDismissed)}
>
<ChevronIcon direction={showDismissed ? 'up' : 'down'} />
<span>
{dismissedNotes.length} dismissed warning{dismissedNotes.length > 1 ? 's' : ''}
</span>
</button>
{showDismissed && (
<div className={css['dismissed-list']}>
{dismissedNotes.map(([componentId, note]) => (
<div key={componentId} className={css['dismissed-item']}>
<span>{getComponentName(project, componentId)}</span>
<Button
variant="ghost"
size="small"
onClick={() => restoreMigrationNote(project, componentId)}
>
Restore
</Button>
</div>
))}
</div>
)}
</div>
);
}
```
## Migration Log Viewer
### Full Log Dialog
```tsx
// packages/noodl-editor/src/editor/src/views/migration/MigrationLogViewer.tsx
interface MigrationLogViewerProps {
session: MigrationSession;
onClose: () => void;
}
function MigrationLogViewer({ session, onClose }: MigrationLogViewerProps) {
const [filter, setFilter] = useState<'all' | 'success' | 'warning' | 'error'>('all');
const [search, setSearch] = useState('');
const filteredLog = session.progress?.log.filter(entry => {
if (filter !== 'all' && entry.level !== filter) {
return false;
}
if (search && !entry.message.toLowerCase().includes(search.toLowerCase())) {
return false;
}
return true;
}) || [];
const exportLog = () => {
const content = session.progress?.log
.map(e => `[${e.timestamp}] [${e.level.toUpperCase()}] ${e.component || ''}: ${e.message}`)
.join('\n');
downloadFile('migration-log.txt', content);
};
return (
<Dialog
title="Migration Log"
size="large"
onClose={onClose}
>
<div className={css['log-viewer']}>
{/* Summary Stats */}
<div className={css['log-summary']}>
<StatPill
label="Total"
value={session.progress?.log.length || 0}
/>
<StatPill
label="Success"
value={session.progress?.log.filter(e => e.level === 'success').length || 0}
variant="success"
/>
<StatPill
label="Warnings"
value={session.progress?.log.filter(e => e.level === 'warning').length || 0}
variant="warning"
/>
<StatPill
label="Errors"
value={session.progress?.log.filter(e => e.level === 'error').length || 0}
variant="error"
/>
{session.ai?.enabled && (
<StatPill
label="AI Cost"
value={`$${session.result?.totalCost.toFixed(2) || '0.00'}`}
variant="info"
/>
)}
</div>
{/* Filters */}
<div className={css['log-filters']}>
<input
type="text"
placeholder="Search log..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select
value={filter}
onChange={(e) => setFilter(e.target.value as any)}
>
<option value="all">All Levels</option>
<option value="success">Success</option>
<option value="warning">Warnings</option>
<option value="error">Errors</option>
</select>
<Button variant="secondary" size="small" onClick={exportLog}>
Export Log
</Button>
</div>
{/* Log Entries */}
<div className={css['log-entries']}>
{filteredLog.map((entry, i) => (
<div
key={i}
className={css['log-entry', `log-entry--${entry.level}`]}
>
<span className={css['log-time']}>
{formatTime(entry.timestamp)}
</span>
<span className={css['log-level']}>
{entry.level.toUpperCase()}
</span>
{entry.component && (
<span className={css['log-component']}>
{entry.component}
</span>
)}
<span className={css['log-message']}>
{entry.message}
</span>
{entry.cost && (
<span className={css['log-cost']}>
${entry.cost.toFixed(3)}
</span>
)}
{entry.details && (
<details className={css['log-details']}>
<summary>Details</summary>
<pre>{entry.details}</pre>
</details>
)}
</div>
))}
{filteredLog.length === 0 && (
<div className={css['log-empty']}>
No log entries match your filters
</div>
)}
</div>
</div>
<DialogActions>
<Button variant="primary" onClick={onClose}>
Close
</Button>
</DialogActions>
</Dialog>
);
}
```
## Code Diff Viewer
### View Changes in Components
```tsx
// packages/noodl-editor/src/editor/src/views/migration/CodeDiffViewer.tsx
interface CodeDiffViewerProps {
componentName: string;
originalCode: string;
migratedCode: string;
changes: string[];
onClose: () => void;
}
function CodeDiffViewer({
componentName,
originalCode,
migratedCode,
changes,
onClose
}: CodeDiffViewerProps) {
const [viewMode, setViewMode] = useState<'split' | 'unified'>('split');
return (
<Dialog
title={`Code Changes: ${componentName}`}
size="fullscreen"
onClose={onClose}
>
<div className={css['diff-viewer']}>
{/* Change Summary */}
<div className={css['diff-changes']}>
<h4>Changes Made</h4>
<ul>
{changes.map((change, i) => (
<li key={i}>
<CheckIcon size={12} />
{change}
</li>
))}
</ul>
</div>
{/* View Mode Toggle */}
<div className={css['diff-toolbar']}>
<ToggleGroup
value={viewMode}
onChange={setViewMode}
options={[
{ value: 'split', label: 'Side by Side' },
{ value: 'unified', label: 'Unified' }
]}
/>
<Button
variant="secondary"
size="small"
onClick={() => copyToClipboard(migratedCode)}
>
Copy Migrated Code
</Button>
</div>
{/* Diff Display */}
<div className={css['diff-content']}>
{viewMode === 'split' ? (
<SplitDiff
original={originalCode}
modified={migratedCode}
/>
) : (
<UnifiedDiff
original={originalCode}
modified={migratedCode}
/>
)}
</div>
</div>
<DialogActions>
<Button variant="primary" onClick={onClose}>
Close
</Button>
</DialogActions>
</Dialog>
);
}
// Using Monaco Editor for diff view
function SplitDiff({ original, modified }: { original: string; modified: string }) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const editor = monaco.editor.createDiffEditor(containerRef.current, {
renderSideBySide: true,
readOnly: true,
theme: 'vs-dark'
});
editor.setModel({
original: monaco.editor.createModel(original, 'javascript'),
modified: monaco.editor.createModel(modified, 'javascript')
});
return () => editor.dispose();
}, [original, modified]);
return <div ref={containerRef} className={css['monaco-diff']} />;
}
```
## Testing Checklist
- [ ] Status badges appear on components
- [ ] Clicking badge opens migration notes panel
- [ ] AI suggestions display with markdown formatting
- [ ] Dismiss functionality works
- [ ] Dismissed warnings can be restored
- [ ] Filter shows only matching components
- [ ] Migration info appears in project info
- [ ] Log viewer shows all entries
- [ ] Log can be filtered and searched
- [ ] Log can be exported
- [ ] Code diff viewer shows changes
- [ ] Diff supports split and unified modes

View File

@@ -0,0 +1,477 @@
# 05 - New Project Notice
## Overview
When creating new projects, inform users that OpenNoodl 1.2+ uses React 19 and is not backwards compatible with older Noodl versions. Keep the messaging positive and focused on the benefits.
## Create Project Dialog
### Updated UI
```tsx
// packages/noodl-editor/src/editor/src/views/dialogs/CreateProjectDialog.tsx
interface CreateProjectDialogProps {
onClose: () => void;
onCreateProject: (config: ProjectConfig) => void;
}
interface ProjectConfig {
name: string;
location: string;
template?: string;
}
function CreateProjectDialog({ onClose, onCreateProject }: CreateProjectDialogProps) {
const [name, setName] = useState('');
const [location, setLocation] = useState(getDefaultProjectLocation());
const [template, setTemplate] = useState<string | undefined>();
const [showInfo, setShowInfo] = useState(true);
const handleCreate = () => {
onCreateProject({ name, location, template });
};
const projectPath = path.join(location, slugify(name));
return (
<Dialog
title="Create New Project"
icon={<SparklesIcon />}
onClose={onClose}
>
<div className={css['create-project']}>
{/* Project Name */}
<FormField label="Project Name">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Awesome App"
autoFocus
/>
</FormField>
{/* Location */}
<FormField label="Location">
<div className={css['location-field']}>
<input
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
className={css['location-input']}
/>
<Button
variant="secondary"
onClick={async () => {
const selected = await selectFolder();
if (selected) setLocation(selected);
}}
>
Browse
</Button>
</div>
<span className={css['location-preview']}>
Project will be created at: <code>{projectPath}</code>
</span>
</FormField>
{/* Template Selection (Optional) */}
<FormField label="Start From" optional>
<TemplateSelector
value={template}
onChange={setTemplate}
templates={[
{ id: undefined, name: 'Blank Project', description: 'Start from scratch' },
{ id: 'hello-world', name: 'Hello World', description: 'Simple starter' },
{ id: 'dashboard', name: 'Dashboard', description: 'Data visualization template' }
]}
/>
</FormField>
{/* React 19 Info Box */}
{showInfo && (
<InfoBox
type="info"
dismissible
onDismiss={() => setShowInfo(false)}
>
<div className={css['react-info']}>
<div className={css['react-info__header']}>
<ReactIcon size={16} />
<strong>OpenNoodl 1.2+ uses React 19</strong>
</div>
<p>
Projects created with this version are not compatible with the
original Noodl app or older forks. This ensures you get the latest
React features and performance improvements.
</p>
<a
href="https://docs.opennoodl.com/react-19"
target="_blank"
className={css['react-info__link']}
>
Learn about React 19 benefits
</a>
</div>
</InfoBox>
)}
</div>
<DialogActions>
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleCreate}
disabled={!name.trim()}
>
Create Project
</Button>
</DialogActions>
</Dialog>
);
}
```
### CSS Styles
```scss
// packages/noodl-editor/src/editor/src/styles/create-project.scss
.create-project {
display: flex;
flex-direction: column;
gap: 20px;
min-width: 500px;
}
.location-field {
display: flex;
gap: 8px;
}
.location-input {
flex: 1;
}
.location-preview {
display: block;
margin-top: 4px;
font-size: 12px;
color: var(--color-text-secondary);
code {
background: var(--color-bg-tertiary);
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
}
}
.react-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.react-info__header {
display: flex;
align-items: center;
gap: 8px;
svg {
color: var(--color-react);
}
}
.react-info__link {
align-self: flex-start;
font-size: 13px;
color: var(--color-link);
&:hover {
text-decoration: underline;
}
}
```
## First Launch Welcome
### First-Time User Experience
For users launching OpenNoodl for the first time after the React 19 update:
```tsx
// packages/noodl-editor/src/editor/src/views/dialogs/WelcomeDialog.tsx
interface WelcomeDialogProps {
isUpdate: boolean; // true if upgrading from older version
onClose: () => void;
onCreateProject: () => void;
onOpenProject: () => void;
}
function WelcomeDialog({
isUpdate,
onClose,
onCreateProject,
onOpenProject
}: WelcomeDialogProps) {
return (
<Dialog
title={isUpdate ? "Welcome to OpenNoodl 1.2" : "Welcome to OpenNoodl"}
size="medium"
onClose={onClose}
>
<div className={css['welcome-dialog']}>
{/* Header */}
<div className={css['welcome-header']}>
<OpenNoodlLogo size={48} />
<div>
<h2>OpenNoodl 1.2</h2>
<span className={css['version-badge']}>React 19</span>
</div>
</div>
{/* Update Message (if upgrading) */}
{isUpdate && (
<div className={css['update-notice']}>
<SparklesIcon size={20} />
<div>
<h3>What's New</h3>
<ul>
<li>
<strong>React 19 Runtime</strong> - Modern React with
improved performance and new features
</li>
<li>
<strong>Migration Assistant</strong> - AI-powered tool to
upgrade legacy projects
</li>
<li>
<strong>New Nodes</strong> - HTTP Request, improved data
handling, and more
</li>
</ul>
</div>
</div>
)}
{/* Migration Note for Update */}
{isUpdate && (
<InfoBox type="info">
<p>
<strong>Have existing projects?</strong> When you open them,
OpenNoodl will guide you through migrating to React 19. Your
original projects are never modified.
</p>
</InfoBox>
)}
{/* Getting Started */}
<div className={css['welcome-actions']}>
<ActionCard
icon={<PlusIcon />}
title="Create New Project"
description="Start fresh with React 19"
onClick={onCreateProject}
primary
/>
<ActionCard
icon={<FolderOpenIcon />}
title="Open Existing Project"
description={isUpdate
? "Opens with migration assistant if needed"
: "Continue where you left off"
}
onClick={onOpenProject}
/>
</div>
{/* Resources */}
<div className={css['welcome-resources']}>
<a href="https://docs.opennoodl.com/getting-started" target="_blank">
<BookIcon size={14} />
Documentation
</a>
<a href="https://discord.opennoodl.com" target="_blank">
<DiscordIcon size={14} />
Community
</a>
<a href="https://github.com/opennoodl" target="_blank">
<GithubIcon size={14} />
GitHub
</a>
</div>
</div>
</Dialog>
);
}
```
## Compatibility Check for Templates
### Template Metadata
```typescript
// packages/noodl-editor/src/editor/src/models/templates.ts
interface ProjectTemplate {
id: string;
name: string;
description: string;
thumbnail?: string;
runtimeVersion: 'react17' | 'react19';
minEditorVersion?: string;
tags: string[];
}
async function getAvailableTemplates(): Promise<ProjectTemplate[]> {
const templates = await fetchTemplates();
// Filter to only React 19 compatible templates
return templates.filter(t => t.runtimeVersion === 'react19');
}
async function fetchTemplates(): Promise<ProjectTemplate[]> {
// Fetch from community repository or local
return [
{
id: 'blank',
name: 'Blank Project',
description: 'Start from scratch',
runtimeVersion: 'react19',
tags: ['starter']
},
{
id: 'hello-world',
name: 'Hello World',
description: 'Simple starter with basic components',
runtimeVersion: 'react19',
tags: ['starter', 'beginner']
},
{
id: 'dashboard',
name: 'Dashboard',
description: 'Data visualization with charts and tables',
runtimeVersion: 'react19',
tags: ['data', 'charts']
},
{
id: 'form-app',
name: 'Form Application',
description: 'Multi-step form with validation',
runtimeVersion: 'react19',
tags: ['forms', 'business']
}
];
}
```
## Settings for Info Box Dismissal
### User Preferences
```typescript
// packages/noodl-editor/src/editor/src/models/UserPreferences.ts
interface UserPreferences {
// Existing preferences...
// Migration related
dismissedReactInfoInCreateDialog: boolean;
dismissedWelcomeDialog: boolean;
lastSeenVersion: string;
}
export function shouldShowWelcomeDialog(): boolean {
const prefs = getUserPreferences();
const currentVersion = getAppVersion();
// Show if never seen or version changed significantly
if (!prefs.lastSeenVersion) {
return true;
}
const [lastMajor, lastMinor] = prefs.lastSeenVersion.split('.').map(Number);
const [currentMajor, currentMinor] = currentVersion.split('.').map(Number);
// Show on major or minor version bump
return currentMajor > lastMajor || currentMinor > lastMinor;
}
export function markWelcomeDialogSeen(): void {
updateUserPreferences({
dismissedWelcomeDialog: true,
lastSeenVersion: getAppVersion()
});
}
```
## Documentation Link Content
### React 19 Benefits Page (External)
Create content for `https://docs.opennoodl.com/react-19`:
```markdown
# React 19 in OpenNoodl
OpenNoodl 1.2 uses React 19, bringing significant improvements to your projects.
## Benefits
### Better Performance
- Automatic batching of state updates
- Improved rendering efficiency
- Smaller bundle sizes
### Modern React Features
- Use modern hooks in custom code
- Better error boundaries
- Improved Suspense support
### Future-Proof
- Stay current with React ecosystem
- Better library compatibility
- Long-term support
## What This Means for You
### New Projects
New projects automatically use React 19. No extra configuration needed.
### Existing Projects
Legacy projects (React 17) can be migrated using our built-in migration
assistant. The process is straightforward and preserves your original
project.
## Compatibility Notes
- Projects created in OpenNoodl 1.2+ won't open in older Noodl versions
- Most built-in nodes work identically in both versions
- Custom JavaScript code may need minor updates (the migration assistant
can help with this)
## Learn More
- [Migration Guide](/migration/react19)
- [What's New in React 19](https://react.dev/blog/2024/04/25/react-19)
- [OpenNoodl Release Notes](/releases/1.2)
```
## Testing Checklist
- [ ] Create project dialog shows React 19 info
- [ ] Info box can be dismissed
- [ ] Dismissal preference is persisted
- [ ] Project path preview updates correctly
- [ ] Welcome dialog shows on first launch
- [ ] Welcome dialog shows after version update
- [ ] Welcome dialog shows migration note for updates
- [ ] Action cards navigate correctly
- [ ] Resource links open in browser
- [ ] Templates are filtered to React 19 only

View File

@@ -0,0 +1,66 @@
# React 19 Migration System - Changelog
## [Unreleased]
### Session 1: Foundation + Detection
#### 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:
- `types.ts` - Complete TypeScript interfaces for migration system:
- Runtime version types (`RuntimeVersion`, `RuntimeVersionInfo`, `ConfidenceLevel`)
- Migration issue types (`MigrationIssue`, `MigrationIssueType`, `ComponentMigrationInfo`)
- Session types (`MigrationSession`, `MigrationScan`, `MigrationStep`, `MigrationPhase`)
- AI types (`AIConfig`, `AIBudget`, `AIPreferences`, `AIMigrationResponse`)
- Project manifest extensions (`ProjectMigrationMetadata`, `ComponentMigrationNote`)
- Legacy pattern definitions (`LegacyPattern`, `LegacyPatternScan`)
- `ProjectScanner.ts` - Version detection and legacy pattern scanning:
- 5-tier detection system with confidence levels
- `detectRuntimeVersion()` - Main detection function
- `scanForLegacyPatterns()` - Scans for React 17 patterns
- `scanProjectForMigration()` - Full project migration scan
- 13 legacy React patterns detected (componentWillMount, string refs, etc.)
- `MigrationSession.ts` - State machine for migration workflow:
- `MigrationSessionManager` class extending EventDispatcher
- Step transitions (confirm → scanning → report → configureAi → migrating → complete/failed)
- Progress tracking and logging
- Helper functions (`checkProjectNeedsMigration`, `getStepLabel`, etc.)
- `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
├── types.ts
├── ProjectScanner.ts
└── MigrationSession.ts
```
---
## Overview
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
- [03-AI-MIGRATION.md](./03-AI-MIGRATION.md) - AI-assisted code migration
- [04-POST-MIGRATION-UX.md](./04-POST-MIGRATION-UX.md) - Editor experience after migration
- [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)
4. **Session 4**: AI Migration + Polish (Claude integration, UX)

View File

@@ -0,0 +1,50 @@
# 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)
- [x] Create MigrationSession.ts (state machine)
- [ ] Test scanner against example project (requires editor build)
- [x] Create CHANGELOG.md tracking file
- [x] Create index.ts module exports
## Session 2: Wizard UI (Basic Flow)
- [ ] MigrationWizard.tsx container
- [ ] ConfirmStep.tsx component
- [ ] ScanningStep.tsx component
- [ ] ReportStep.tsx component
- [ ] CompleteStep.tsx component
- [ ] MigrationExecutor.ts (project copy + basic fixes)
- [ ] DialogLayerModel integration for showing wizard
## Session 3: Projects View Integration
- [ ] Update projectsview.ts to detect and show legacy badges
- [ ] Add "Migrate Project" button to project cards
- [ ] Add "Open Read-Only" button to project cards
- [ ] Create EditorBanner.tsx for read-only mode warning
- [ ] Wire open project flow to detect legacy projects
## 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
- [ ] Post-migration component status badges
- [ ] MigrationNotesPanel.tsx
## Post-Migration UX
- [ ] Component panel status indicators
- [ ] Migration notes display
- [ ] Dismiss functionality
- [ ] Project Info panel migration section
- [ ] Component filter by migration status
## Polish Items
- [ ] New project dialog React 19 notice
- [ ] Welcome dialog for version updates
- [ ] Documentation links throughout UI
- [ ] Migration log viewer