Files
OpenNoodl/dev-docs/tasks/phase-2-react-migration/TASK-004-runtime-migration-system/04-POST-MIGRATION-UX.md

20 KiB

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

// 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

// 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:

// 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

// 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

// 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

// 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

// 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

// 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

// 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