# Migration Plan: Next.js to Vite + Electron + TanStack > **Document Version**: 1.1 > **Date**: December 2025 > **Status**: Phase 2 Complete - Core Migration Done > **Branch**: refactor/frontend --- ## Table of Contents 1. [Executive Summary](#executive-summary) 2. [Current Architecture Assessment](#current-architecture-assessment) 3. [Proposed New Architecture](#proposed-new-architecture) 4. [Folder Structure](#folder-structure) 5. [Shared Packages (libs/)](#shared-packages-libs) 6. [Type-Safe Electron Implementation](#type-safe-electron-implementation) 7. [Components Refactoring](#components-refactoring) 8. [Web + Electron Dual Support](#web--electron-dual-support) 9. [Migration Phases](#migration-phases) 10. [Expected Benefits](#expected-benefits) 11. [Risk Mitigation](#risk-mitigation) --- ## Executive Summary ### Why Migrate? Our current Next.js implementation uses **less than 5%** of the framework's capabilities. We're essentially running a static SPA with unnecessary overhead: | Next.js Feature | Our Usage | | ---------------------- | ---------------------------- | | Server-Side Rendering | ❌ Not used | | Static Site Generation | ❌ Not used | | API Routes | ⚠️ Only 2 test endpoints | | Image Optimization | ❌ Not used | | Dynamic Routing | ❌ Not used | | App Router | ⚠️ File structure only | | Metadata API | ⚠️ Title/description only | | Static Export | ✅ Used (`output: "export"`) | ### Migration Benefits | Metric | Current (Next.js) | Expected (Vite) | | ---------------------- | ----------------- | ------------------ | | Dev server startup | ~8-15s | ~1-3s | | HMR speed | ~500ms-2s | ~50-100ms | | Production build | ~45-90s | ~15-30s | | Bundle overhead | Next.js runtime | None | | Type safety (Electron) | 0% | 100% | | Debug capabilities | Limited | Full debug console | ### Target Stack - **Bundler**: Vite - **Framework**: React 19 - **Routing**: TanStack Router (file-based) - **Data Fetching**: TanStack Query - **State**: Zustand (unchanged) - **Styling**: Tailwind CSS 4 (unchanged) - **Desktop**: Electron (TypeScript rewrite) --- ## Current Architecture Assessment ### Data Flow Diagram ``` ┌─────────────────────────────────────────────────────────────────┐ │ ELECTRON APP │ ├─────────────────────────────────────────────────────────────────┤ │ ┌─────────────────┐ HTTP/WS ┌─────────────────┐ │ │ │ React SPA │ ←──────────────────→ │ Backend Server │ │ │ │ (Next.js) │ localhost:3008 │ (Express) │ │ │ │ │ │ │ │ │ │ • Zustand Store │ │ • AI Providers │ │ │ │ • 16 Views │ │ • Git/FS Ops │ │ │ │ • 180+ Comps │ │ • Terminal │ │ │ └────────┬────────┘ └─────────────────┘ │ │ │ │ │ │ IPC (minimal - dialogs/shell only) │ │ ↓ │ │ ┌─────────────────┐ │ │ │ Electron Main │ • File dialogs │ │ │ (main.js) │ • Shell operations │ │ │ **NO TYPES** │ • App paths │ │ └─────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` ### Current Electron Layer Issues | Issue | Impact | Solution | | ----------------------- | --------------------------- | ------------------------ | | Pure JavaScript | No compile-time safety | Migrate to TypeScript | | Untyped IPC handlers | Runtime errors | IPC Schema with generics | | String literal channels | Typos cause silent failures | Const enums | | No debug tooling | Hard to diagnose issues | Debug console feature | | Monolithic main.js | Hard to maintain | Modular IPC organization | ### Current Component Structure Issues | View File | Lines | Issue | | ------------------ | ----- | ---------------------------------- | | spec-view.tsx | 1,230 | Exceeds 500-line threshold | | analysis-view.tsx | 1,134 | Exceeds 500-line threshold | | agent-view.tsx | 916 | Exceeds 500-line threshold | | welcome-view.tsx | 815 | Exceeds 500-line threshold | | context-view.tsx | 735 | Exceeds 500-line threshold | | terminal-view.tsx | 697 | Exceeds 500-line threshold | | interview-view.tsx | 637 | Exceeds 500-line threshold | | board-view.tsx | 685 | ✅ Already has subfolder structure | --- ## Proposed New Architecture ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ MIGRATED ARCHITECTURE │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────────────────────────────────────────────────────────┐ │ │ │ @automaker/app (Vite + React) │ │ │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────────┐ │ │ │ │ │ TanStack │ │ TanStack │ │ Zustand │ │ │ │ │ │ Router │ │ Query │ │ Store │ │ │ │ │ │ (file-based) │ │ (data fetch) │ │ (UI state) │ │ │ │ │ └────────────────┘ └────────────────┘ └────────────────────┘ │ │ │ │ │ │ │ │ src/ │ │ │ │ ├── routes/ # TanStack file-based routes │ │ │ │ ├── components/ # Refactored per folder-pattern.md │ │ │ │ ├── hooks/ # React hooks │ │ │ │ ├── store/ # Zustand stores │ │ │ │ ├── lib/ # Utilities │ │ │ │ └── config/ # Configuration │ │ │ └──────────────────────────────────────────────────────────────────┘ │ │ │ │ │ HTTP/WS (unchanged) │ Type-Safe IPC │ │ ↓ │ │ ┌──────────────────────────────────────────────────────────────────┐ │ │ │ Electron Layer (TypeScript) │ │ │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ electron/ │ │ │ │ ├── main.ts # Main process entry │ │ │ │ ├── preload.ts # Context bridge exposure │ │ │ │ ├── debug-console/ # Debug console feature │ │ │ │ └── ipc/ # Modular IPC handlers │ │ │ │ ├── ipc-schema.ts # Type definitions │ │ │ │ ├── dialog/ # File dialogs │ │ │ │ ├── shell/ # Shell operations │ │ │ │ └── server/ # Server management │ │ │ └──────────────────────────────────────────────────────────────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────────────────┐ │ │ │ @automaker/server (unchanged) │ │ │ └──────────────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────┐ │ SHARED PACKAGES (libs/) │ ├─────────────────────────────────────────────────────────────────────────┤ │ @automaker/types # API contracts, model definitions │ │ @automaker/utils # Shared utilities (error handling, etc.) │ │ @automaker/platform # OS-specific utilities, path handling │ │ @automaker/model-resolver # Model string resolution │ │ @automaker/ipc-types # IPC channel type definitions │ └─────────────────────────────────────────────────────────────────────────┘ ``` --- ## Folder Structure ### apps/ui/ (After Migration) ``` apps/ui/ ├── electron/ # Electron main process (TypeScript) │ ├── main.ts # Main entry point │ ├── preload.ts # Context bridge │ ├── tsconfig.json # Electron-specific TS config │ ├── debug-console/ │ │ ├── debug-console.html │ │ ├── debug-console-preload.ts │ │ └── debug-mode.ts │ ├── ipc/ │ │ ├── ipc-schema.ts # Central type definitions │ │ ├── context-exposer.ts # Exposes all contexts to renderer │ │ ├── listeners-register.ts # Registers all main process handlers │ │ ├── dialog/ │ │ │ ├── dialog-channels.ts # Channel constants │ │ │ ├── dialog-context.ts # Preload exposure │ │ │ └── dialog-listeners.ts # Main process handlers │ │ ├── shell/ │ │ │ ├── shell-channels.ts │ │ │ ├── shell-context.ts │ │ │ └── shell-listeners.ts │ │ ├── app-info/ │ │ │ ├── app-info-channels.ts │ │ │ ├── app-info-context.ts │ │ │ └── app-info-listeners.ts │ │ └── server/ │ │ ├── server-channels.ts │ │ ├── server-context.ts │ │ └── server-listeners.ts │ └── helpers/ │ ├── server-manager.ts # Backend server spawn/health │ ├── static-server.ts # Production static file server │ ├── window-helpers.ts # Window utilities │ └── window-registry.ts # Multi-window tracking │ ├── src/ │ ├── routes/ # TanStack Router (file-based) │ │ ├── __root.tsx # Root layout │ │ ├── index.tsx # Welcome/home (default route) │ │ ├── board.tsx # Board view │ │ ├── agent.tsx # Agent view │ │ ├── settings.tsx # Settings view │ │ ├── setup.tsx # Setup view │ │ ├── terminal.tsx # Terminal view │ │ ├── spec.tsx # Spec view │ │ ├── context.tsx # Context view │ │ ├── profiles.tsx # Profiles view │ │ ├── interview.tsx # Interview view │ │ ├── wiki.tsx # Wiki view │ │ ├── analysis.tsx # Analysis view │ │ └── agent-tools.tsx # Agent tools view │ │ │ ├── components/ # Refactored per folder-pattern.md │ │ ├── ui/ # Global UI primitives (unchanged) │ │ ├── layout/ │ │ │ ├── sidebar.tsx │ │ │ ├── base-layout.tsx │ │ │ └── index.ts │ │ ├── dialogs/ # Global dialogs │ │ │ ├── index.ts │ │ │ ├── new-project-modal.tsx │ │ │ ├── workspace-picker-modal.tsx │ │ │ └── file-browser-dialog.tsx │ │ └── views/ # Complex view components │ │ ├── board-view/ # ✅ Already structured │ │ ├── settings-view/ # Needs dialogs reorganization │ │ ├── setup-view/ # ✅ Already structured │ │ ├── profiles-view/ # ✅ Already structured │ │ ├── agent-view/ # NEW: needs subfolder │ │ │ ├── components/ │ │ │ │ ├── index.ts │ │ │ │ ├── message-list.tsx │ │ │ │ ├── message-input.tsx │ │ │ │ └── session-sidebar.tsx │ │ │ ├── dialogs/ │ │ │ │ ├── index.ts │ │ │ │ ├── delete-session-dialog.tsx │ │ │ │ └── delete-all-archived-dialog.tsx │ │ │ └── hooks/ │ │ │ ├── index.ts │ │ │ └── use-agent-state.ts │ │ ├── spec-view/ # NEW: needs subfolder (1230 lines!) │ │ ├── analysis-view/ # NEW: needs subfolder (1134 lines!) │ │ ├── context-view/ # NEW: needs subfolder │ │ ├── welcome-view/ # NEW: needs subfolder │ │ ├── interview-view/ # NEW: needs subfolder │ │ └── terminal-view/ # Expand existing │ │ │ ├── hooks/ # Global hooks │ ├── store/ # Zustand stores │ ├── lib/ # Utilities │ ├── config/ # Configuration │ ├── contexts/ # React contexts │ ├── types/ # Type definitions │ ├── App.tsx # Root component │ ├── renderer.ts # Vite entry point │ └── routeTree.gen.ts # Generated by TanStack Router │ ├── index.html # Vite HTML entry ├── vite.config.mts # Vite configuration ├── tsconfig.json # TypeScript config (renderer) ├── package.json └── tailwind.config.ts ``` --- ## Shared Packages (libs/) ### Package Overview ``` libs/ ├── @automaker/types # API contracts, model definitions ├── @automaker/utils # General utilities (error handling, logger) ├── @automaker/platform # OS-specific utilities, path handling ├── @automaker/model-resolver # Model string resolution └── @automaker/ipc-types # IPC channel type definitions ``` ### @automaker/types Shared type definitions for API contracts between frontend and backend. ``` libs/types/ ├── src/ │ ├── api.ts # API response types │ ├── models.ts # ModelDefinition, ProviderStatus │ ├── features.ts # Feature, FeatureStatus, Priority │ ├── sessions.ts # Session, Message types │ ├── agent.ts # Agent types │ ├── git.ts # Git operation types │ ├── worktree.ts # Worktree types │ └── index.ts # Barrel export ├── package.json └── tsconfig.json ``` ```typescript // libs/types/src/models.ts export interface ModelDefinition { id: string; name: string; provider: ProviderType; contextWindow: number; maxOutputTokens: number; capabilities: ModelCapabilities; } export interface ModelCapabilities { vision: boolean; toolUse: boolean; streaming: boolean; computerUse: boolean; } export type ProviderType = 'claude' | 'openai' | 'gemini' | 'ollama'; ``` ### @automaker/utils General utilities shared between frontend and backend. ``` libs/utils/ ├── src/ │ ├── error-handler.ts # Error classification & user-friendly messages │ ├── logger.ts # Logging utilities │ ├── conversation-utils.ts # Message formatting & history │ ├── image-utils.ts # Image processing utilities │ ├── string-utils.ts # String manipulation helpers │ └── index.ts ├── package.json └── tsconfig.json ``` ```typescript // libs/utils/src/error-handler.ts export type ErrorType = | 'authentication' | 'rate_limit' | 'network' | 'validation' | 'not_found' | 'server' | 'unknown'; export interface ErrorInfo { type: ErrorType; message: string; userMessage: string; retryable: boolean; statusCode?: number; } export function classifyError(error: unknown): ErrorInfo; export function getUserFriendlyErrorMessage(error: unknown): string; export function isAbortError(error: unknown): boolean; export function isAuthenticationError(error: unknown): boolean; export function isRateLimitError(error: unknown): boolean; ``` ### @automaker/platform **OS-specific utilities, path handling, and cross-platform helpers.** ``` libs/platform/ ├── src/ │ ├── paths/ │ │ ├── index.ts # Path utilities barrel export │ │ ├── path-resolver.ts # Cross-platform path resolution │ │ ├── path-constants.ts # Common path constants │ │ └── path-validator.ts # Path validation utilities │ ├── os/ │ │ ├── index.ts # OS utilities barrel export │ │ ├── platform-info.ts # Platform detection & info │ │ ├── shell-commands.ts # OS-specific shell commands │ │ └── env-utils.ts # Environment variable utilities │ ├── fs/ │ │ ├── index.ts # FS utilities barrel export │ │ ├── safe-fs.ts # Symlink-safe file operations │ │ ├── temp-files.ts # Temporary file handling │ │ └── permissions.ts # File permission utilities │ └── index.ts # Main barrel export ├── package.json └── tsconfig.json ``` ```typescript // libs/platform/src/paths/path-resolver.ts import path from 'path'; /** * Platform-aware path separator */ export const SEP = path.sep; /** * Normalizes a path to use the correct separator for the current OS */ export function normalizePath(inputPath: string): string { return inputPath.replace(/[/\\]/g, SEP); } /** * Converts a path to POSIX format (forward slashes) * Useful for consistent storage/comparison */ export function toPosixPath(inputPath: string): string { return inputPath.replace(/\\/g, '/'); } /** * Converts a path to Windows format (backslashes) */ export function toWindowsPath(inputPath: string): string { return inputPath.replace(/\//g, '\\'); } /** * Resolves a path relative to a base, handling platform differences */ export function resolvePath(basePath: string, ...segments: string[]): string { return path.resolve(basePath, ...segments); } /** * Gets the relative path from one location to another */ export function getRelativePath(from: string, to: string): string { return path.relative(from, to); } /** * Joins path segments with proper platform separator */ export function joinPath(...segments: string[]): string { return path.join(...segments); } /** * Extracts directory name from a path */ export function getDirname(filePath: string): string { return path.dirname(filePath); } /** * Extracts filename from a path */ export function getBasename(filePath: string, ext?: string): string { return path.basename(filePath, ext); } /** * Extracts file extension from a path */ export function getExtension(filePath: string): string { return path.extname(filePath); } /** * Checks if a path is absolute */ export function isAbsolutePath(inputPath: string): boolean { return path.isAbsolute(inputPath); } /** * Ensures a path is absolute, resolving relative to cwd if needed */ export function ensureAbsolutePath(inputPath: string, basePath?: string): string { if (isAbsolutePath(inputPath)) { return inputPath; } return resolvePath(basePath || process.cwd(), inputPath); } ``` ```typescript // libs/platform/src/paths/path-constants.ts import path from 'path'; import os from 'os'; /** * Common system paths */ export const SYSTEM_PATHS = { /** User's home directory */ home: os.homedir(), /** System temporary directory */ temp: os.tmpdir(), /** Current working directory */ cwd: process.cwd(), } as const; /** * Gets the appropriate app data directory for the current platform */ export function getAppDataPath(appName: string): string { const platform = process.platform; switch (platform) { case 'win32': return path.join( process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), appName ); case 'darwin': return path.join(os.homedir(), 'Library', 'Application Support', appName); default: // Linux and others return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), appName); } } /** * Gets the appropriate cache directory for the current platform */ export function getCachePath(appName: string): string { const platform = process.platform; switch (platform) { case 'win32': return path.join( process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), appName, 'Cache' ); case 'darwin': return path.join(os.homedir(), 'Library', 'Caches', appName); default: return path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), appName); } } /** * Gets the appropriate logs directory for the current platform */ export function getLogsPath(appName: string): string { const platform = process.platform; switch (platform) { case 'win32': return path.join( process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), appName, 'Logs' ); case 'darwin': return path.join(os.homedir(), 'Library', 'Logs', appName); default: return path.join( process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'), appName, 'logs' ); } } /** * Gets the user's Documents directory */ export function getDocumentsPath(): string { const platform = process.platform; switch (platform) { case 'win32': return process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'Documents') : path.join(os.homedir(), 'Documents'); case 'darwin': return path.join(os.homedir(), 'Documents'); default: return process.env.XDG_DOCUMENTS_DIR || path.join(os.homedir(), 'Documents'); } } /** * Gets the user's Desktop directory */ export function getDesktopPath(): string { const platform = process.platform; switch (platform) { case 'win32': return process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'Desktop') : path.join(os.homedir(), 'Desktop'); case 'darwin': return path.join(os.homedir(), 'Desktop'); default: return process.env.XDG_DESKTOP_DIR || path.join(os.homedir(), 'Desktop'); } } ``` ```typescript // libs/platform/src/paths/path-validator.ts import path from 'path'; import { isAbsolutePath } from './path-resolver'; /** * Characters that are invalid in file/directory names on Windows */ const WINDOWS_INVALID_CHARS = /[<>:"|?*\x00-\x1f]/g; /** * Reserved names on Windows (case-insensitive) */ const WINDOWS_RESERVED_NAMES = [ 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9', ]; export interface PathValidationResult { valid: boolean; errors: string[]; sanitized?: string; } /** * Validates a filename for the current platform */ export function validateFilename(filename: string): PathValidationResult { const errors: string[] = []; if (!filename || filename.trim().length === 0) { return { valid: false, errors: ['Filename cannot be empty'] }; } // Check for path separators (filename shouldn't be a path) if (filename.includes('/') || filename.includes('\\')) { errors.push('Filename cannot contain path separators'); } // Platform-specific checks if (process.platform === 'win32') { if (WINDOWS_INVALID_CHARS.test(filename)) { errors.push('Filename contains invalid characters for Windows'); } const nameWithoutExt = filename.split('.')[0].toUpperCase(); if (WINDOWS_RESERVED_NAMES.includes(nameWithoutExt)) { errors.push(`"${nameWithoutExt}" is a reserved name on Windows`); } if (filename.endsWith(' ') || filename.endsWith('.')) { errors.push('Filename cannot end with a space or period on Windows'); } } // Check length if (filename.length > 255) { errors.push('Filename exceeds maximum length of 255 characters'); } return { valid: errors.length === 0, errors, sanitized: errors.length > 0 ? sanitizeFilename(filename) : filename, }; } /** * Sanitizes a filename for cross-platform compatibility */ export function sanitizeFilename(filename: string): string { let sanitized = filename.replace(WINDOWS_INVALID_CHARS, '_').replace(/[/\\]/g, '_').trim(); // Handle Windows reserved names const nameWithoutExt = sanitized.split('.')[0].toUpperCase(); if (WINDOWS_RESERVED_NAMES.includes(nameWithoutExt)) { sanitized = '_' + sanitized; } // Remove trailing spaces and periods (Windows) sanitized = sanitized.replace(/[\s.]+$/, ''); // Ensure not empty if (!sanitized) { sanitized = 'unnamed'; } // Truncate if too long if (sanitized.length > 255) { const ext = path.extname(sanitized); const name = path.basename(sanitized, ext); sanitized = name.slice(0, 255 - ext.length) + ext; } return sanitized; } /** * Validates a full path for the current platform */ export function validatePath(inputPath: string): PathValidationResult { const errors: string[] = []; if (!inputPath || inputPath.trim().length === 0) { return { valid: false, errors: ['Path cannot be empty'] }; } // Check total path length const maxPathLength = process.platform === 'win32' ? 260 : 4096; if (inputPath.length > maxPathLength) { errors.push(`Path exceeds maximum length of ${maxPathLength} characters`); } // Validate each segment const segments = inputPath.split(/[/\\]/).filter(Boolean); for (const segment of segments) { // Skip drive letters on Windows if (process.platform === 'win32' && /^[a-zA-Z]:$/.test(segment)) { continue; } const segmentValidation = validateFilename(segment); if (!segmentValidation.valid) { errors.push(...segmentValidation.errors.map((e) => `Segment "${segment}": ${e}`)); } } return { valid: errors.length === 0, errors, }; } /** * Checks if a path is within a base directory (prevents directory traversal) */ export function isPathWithin(childPath: string, parentPath: string): boolean { const resolvedChild = path.resolve(childPath); const resolvedParent = path.resolve(parentPath); return resolvedChild.startsWith(resolvedParent + path.sep) || resolvedChild === resolvedParent; } ``` ```typescript // libs/platform/src/os/platform-info.ts import os from 'os'; export type Platform = 'windows' | 'macos' | 'linux' | 'unknown'; export type Architecture = 'x64' | 'arm64' | 'ia32' | 'unknown'; export interface PlatformInfo { platform: Platform; arch: Architecture; release: string; hostname: string; username: string; cpus: number; totalMemory: number; freeMemory: number; isWsl: boolean; isDocker: boolean; } /** * Gets the normalized platform name */ export function getPlatform(): Platform { switch (process.platform) { case 'win32': return 'windows'; case 'darwin': return 'macos'; case 'linux': return 'linux'; default: return 'unknown'; } } /** * Gets the normalized architecture */ export function getArchitecture(): Architecture { switch (process.arch) { case 'x64': return 'x64'; case 'arm64': return 'arm64'; case 'ia32': return 'ia32'; default: return 'unknown'; } } /** * Checks if running on Windows */ export function isWindows(): boolean { return process.platform === 'win32'; } /** * Checks if running on macOS */ export function isMacOS(): boolean { return process.platform === 'darwin'; } /** * Checks if running on Linux */ export function isLinux(): boolean { return process.platform === 'linux'; } /** * Checks if running in WSL (Windows Subsystem for Linux) */ export function isWsl(): boolean { if (process.platform !== 'linux') return false; try { const release = os.release().toLowerCase(); return release.includes('microsoft') || release.includes('wsl'); } catch { return false; } } /** * Checks if running in Docker container */ export function isDocker(): boolean { try { const fs = require('fs'); return ( fs.existsSync('/.dockerenv') || (fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker')) ); } catch { return false; } } /** * Gets comprehensive platform information */ export function getPlatformInfo(): PlatformInfo { return { platform: getPlatform(), arch: getArchitecture(), release: os.release(), hostname: os.hostname(), username: os.userInfo().username, cpus: os.cpus().length, totalMemory: os.totalmem(), freeMemory: os.freemem(), isWsl: isWsl(), isDocker: isDocker(), }; } /** * Gets the appropriate line ending for the current platform */ export function getLineEnding(): string { return isWindows() ? '\r\n' : '\n'; } /** * Normalizes line endings to the current platform */ export function normalizeLineEndings(text: string): string { const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); return isWindows() ? normalized.replace(/\n/g, '\r\n') : normalized; } ``` ```typescript // libs/platform/src/os/shell-commands.ts import { isWindows, isMacOS } from './platform-info'; export interface ShellCommand { command: string; args: string[]; } /** * Gets the appropriate command to open a file/URL with default application */ export function getOpenCommand(target: string): ShellCommand { if (isWindows()) { return { command: 'cmd', args: ['/c', 'start', '', target] }; } else if (isMacOS()) { return { command: 'open', args: [target] }; } else { return { command: 'xdg-open', args: [target] }; } } /** * Gets the appropriate command to reveal a file in file manager */ export function getRevealCommand(filePath: string): ShellCommand { if (isWindows()) { return { command: 'explorer', args: ['/select,', filePath] }; } else if (isMacOS()) { return { command: 'open', args: ['-R', filePath] }; } else { // Linux: try multiple file managers return { command: 'xdg-open', args: [require('path').dirname(filePath)] }; } } /** * Gets the default shell for the current platform */ export function getDefaultShell(): string { if (isWindows()) { return process.env.COMSPEC || 'cmd.exe'; } return process.env.SHELL || '/bin/sh'; } /** * Gets shell-specific arguments for running a command */ export function getShellArgs(command: string): ShellCommand { if (isWindows()) { return { command: 'cmd.exe', args: ['/c', command] }; } return { command: '/bin/sh', args: ['-c', command] }; } /** * Escapes a string for safe use in shell commands */ export function escapeShellArg(arg: string): string { if (isWindows()) { // Windows cmd.exe escaping return `"${arg.replace(/"/g, '""')}"`; } // POSIX shell escaping return `'${arg.replace(/'/g, "'\\''")}'`; } ``` ```typescript // libs/platform/src/os/env-utils.ts import { isWindows } from './platform-info'; /** * Gets an environment variable with a fallback */ export function getEnv(key: string, fallback?: string): string | undefined { return process.env[key] ?? fallback; } /** * Gets an environment variable, throwing if not set */ export function requireEnv(key: string): string { const value = process.env[key]; if (value === undefined) { throw new Error(`Required environment variable "${key}" is not set`); } return value; } /** * Parses a boolean environment variable */ export function getBoolEnv(key: string, fallback = false): boolean { const value = process.env[key]; if (value === undefined) return fallback; return ['true', '1', 'yes', 'on'].includes(value.toLowerCase()); } /** * Parses a numeric environment variable */ export function getNumericEnv(key: string, fallback: number): number { const value = process.env[key]; if (value === undefined) return fallback; const parsed = parseInt(value, 10); return isNaN(parsed) ? fallback : parsed; } /** * Expands environment variables in a string * Supports both $VAR and ${VAR} syntax, plus %VAR% on Windows */ export function expandEnvVars(input: string): string { let result = input; // Expand ${VAR} syntax result = result.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] || ''); // Expand $VAR syntax (not followed by another word char) result = result.replace( /\$([A-Za-z_][A-Za-z0-9_]*)(?![A-Za-z0-9_])/g, (_, name) => process.env[name] || '' ); // Expand %VAR% syntax (Windows) if (isWindows()) { result = result.replace(/%([^%]+)%/g, (_, name) => process.env[name] || ''); } return result; } /** * Gets the PATH environment variable as an array */ export function getPathEntries(): string[] { const pathVar = process.env.PATH || process.env.Path || ''; const separator = isWindows() ? ';' : ':'; return pathVar.split(separator).filter(Boolean); } /** * Checks if a command is available in PATH */ export function isCommandInPath(command: string): boolean { const pathEntries = getPathEntries(); const extensions = isWindows() ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';') : ['']; const path = require('path'); const fs = require('fs'); for (const dir of pathEntries) { for (const ext of extensions) { const fullPath = path.join(dir, command + ext); try { fs.accessSync(fullPath, fs.constants.X_OK); return true; } catch { // Continue searching } } } return false; } ``` ```typescript // libs/platform/src/fs/safe-fs.ts import fs from 'fs'; import path from 'path'; /** * Safely reads a file, following symlinks but preventing escape from base directory */ export async function safeReadFile( filePath: string, basePath: string, encoding: BufferEncoding = 'utf8' ): Promise { const resolvedPath = path.resolve(filePath); const resolvedBase = path.resolve(basePath); // Resolve symlinks const realPath = await fs.promises.realpath(resolvedPath); const realBase = await fs.promises.realpath(resolvedBase); // Ensure resolved path is within base if (!realPath.startsWith(realBase + path.sep) && realPath !== realBase) { throw new Error(`Path "${filePath}" resolves outside of allowed directory`); } return fs.promises.readFile(realPath, encoding); } /** * Safely writes a file, preventing writes outside base directory */ export async function safeWriteFile( filePath: string, basePath: string, content: string ): Promise { const resolvedPath = path.resolve(filePath); const resolvedBase = path.resolve(basePath); // Ensure path is within base before any symlink resolution if (!resolvedPath.startsWith(resolvedBase + path.sep) && resolvedPath !== resolvedBase) { throw new Error(`Path "${filePath}" is outside of allowed directory`); } // Check parent directory exists and is within base const parentDir = path.dirname(resolvedPath); try { const realParent = await fs.promises.realpath(parentDir); const realBase = await fs.promises.realpath(resolvedBase); if (!realParent.startsWith(realBase + path.sep) && realParent !== realBase) { throw new Error(`Parent directory resolves outside of allowed directory`); } } catch (error) { // Parent doesn't exist, that's OK - we'll create it if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error; } } await fs.promises.mkdir(path.dirname(resolvedPath), { recursive: true }); await fs.promises.writeFile(resolvedPath, content, 'utf8'); } /** * Checks if a path exists and is accessible */ export async function pathExists(filePath: string): Promise { try { await fs.promises.access(filePath); return true; } catch { return false; } } /** * Gets file stats, returning null if file doesn't exist */ export async function safeStat(filePath: string): Promise { try { return await fs.promises.stat(filePath); } catch { return null; } } /** * Recursively removes a directory */ export async function removeDirectory(dirPath: string): Promise { await fs.promises.rm(dirPath, { recursive: true, force: true }); } /** * Copies a file or directory */ export async function copy(src: string, dest: string): Promise { const stats = await fs.promises.stat(src); if (stats.isDirectory()) { await fs.promises.mkdir(dest, { recursive: true }); const entries = await fs.promises.readdir(src, { withFileTypes: true }); for (const entry of entries) { await copy(path.join(src, entry.name), path.join(dest, entry.name)); } } else { await fs.promises.copyFile(src, dest); } } ``` ```typescript // libs/platform/src/index.ts // Main barrel export // Path utilities export * from './paths/path-resolver'; export * from './paths/path-constants'; export * from './paths/path-validator'; // OS utilities export * from './os/platform-info'; export * from './os/shell-commands'; export * from './os/env-utils'; // File system utilities export * from './fs/safe-fs'; ``` ### @automaker/model-resolver Model string resolution shared between frontend and backend. ``` libs/model-resolver/ ├── src/ │ ├── model-map.ts # CLAUDE_MODEL_MAP, DEFAULT_MODELS │ ├── resolver.ts # resolveModelString, getEffectiveModel │ └── index.ts ├── package.json └── tsconfig.json ``` ### @automaker/ipc-types IPC channel type definitions for type-safe Electron communication. ``` libs/ipc-types/ ├── src/ │ ├── schema.ts # IPCSchema interface │ ├── channels.ts # Channel constant enums │ ├── helpers.ts # Type helper functions │ └── index.ts ├── package.json └── tsconfig.json ``` --- ## Type-Safe Electron Implementation ### IPC Schema Definition ```typescript // electron/ipc/ipc-schema.ts import type { OpenDialogOptions, SaveDialogOptions } from 'electron'; // Dialog result types export interface DialogResult { canceled: boolean; filePaths?: string[]; filePath?: string; data?: T; } // App path names (from Electron) export type AppPathName = | 'home' | 'appData' | 'userData' | 'sessionData' | 'temp' | 'exe' | 'module' | 'desktop' | 'documents' | 'downloads' | 'music' | 'pictures' | 'videos' | 'recent' | 'logs' | 'crashDumps'; // Complete IPC Schema with request/response types export interface IPCSchema { // Dialog operations 'dialog:openDirectory': { request: Partial; response: DialogResult; }; 'dialog:openFile': { request: Partial; response: DialogResult; }; 'dialog:saveFile': { request: Partial; response: DialogResult; }; // Shell operations 'shell:openExternal': { request: { url: string }; response: { success: boolean; error?: string }; }; 'shell:openPath': { request: { path: string }; response: { success: boolean; error?: string }; }; // App info 'app:getPath': { request: { name: AppPathName }; response: string; }; 'app:getVersion': { request: void; response: string; }; 'app:isPackaged': { request: void; response: boolean; }; // Server management 'server:getUrl': { request: void; response: string; }; // Connection test ping: { request: void; response: 'pong'; }; // Debug console 'debug:log': { request: { level: DebugLogLevel; category: DebugCategory; message: string; args: unknown[]; }; response: void; }; } export type DebugLogLevel = 'info' | 'warn' | 'error' | 'debug' | 'success'; export type DebugCategory = | 'general' | 'ipc' | 'route' | 'network' | 'perf' | 'state' | 'lifecycle' | 'updater'; // Type extractors export type IPCChannel = keyof IPCSchema; export type IPCRequest = IPCSchema[T]['request']; export type IPCResponse = IPCSchema[T]['response']; ``` ### Modular IPC Organization ```typescript // electron/ipc/dialog/dialog-channels.ts export const DIALOG_CHANNELS = { OPEN_DIRECTORY: 'dialog:openDirectory', OPEN_FILE: 'dialog:openFile', SAVE_FILE: 'dialog:saveFile', } as const; // electron/ipc/dialog/dialog-context.ts import { contextBridge, ipcRenderer } from 'electron'; import { DIALOG_CHANNELS } from './dialog-channels'; import type { IPCRequest, IPCResponse } from '../ipc-schema'; export function exposeDialogContext(): void { contextBridge.exposeInMainWorld('dialogAPI', { openDirectory: (options?: IPCRequest<'dialog:openDirectory'>) => ipcRenderer.invoke(DIALOG_CHANNELS.OPEN_DIRECTORY, options), openFile: (options?: IPCRequest<'dialog:openFile'>) => ipcRenderer.invoke(DIALOG_CHANNELS.OPEN_FILE, options), saveFile: (options?: IPCRequest<'dialog:saveFile'>) => ipcRenderer.invoke(DIALOG_CHANNELS.SAVE_FILE, options), }); } // electron/ipc/dialog/dialog-listeners.ts import { ipcMain, dialog, BrowserWindow } from 'electron'; import { DIALOG_CHANNELS } from './dialog-channels'; import type { IPCRequest, IPCResponse } from '../ipc-schema'; import { debugLog } from '../../helpers/debug-mode'; export function addDialogEventListeners(mainWindow: BrowserWindow): void { ipcMain.handle( DIALOG_CHANNELS.OPEN_DIRECTORY, async (_, options: IPCRequest<'dialog:openDirectory'> = {}) => { debugLog.ipc(`OPEN_DIRECTORY called with options: ${JSON.stringify(options)}`); const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory', 'createDirectory'], ...options, }); debugLog.ipc( `OPEN_DIRECTORY result: canceled=${result.canceled}, paths=${result.filePaths.length}` ); return { canceled: result.canceled, filePaths: result.filePaths, } satisfies IPCResponse<'dialog:openDirectory'>; } ); ipcMain.handle( DIALOG_CHANNELS.OPEN_FILE, async (_, options: IPCRequest<'dialog:openFile'> = {}) => { debugLog.ipc(`OPEN_FILE called`); const result = await dialog.showOpenDialog(mainWindow, { properties: ['openFile'], ...options, }); return { canceled: result.canceled, filePaths: result.filePaths, } satisfies IPCResponse<'dialog:openFile'>; } ); ipcMain.handle( DIALOG_CHANNELS.SAVE_FILE, async (_, options: IPCRequest<'dialog:saveFile'> = {}) => { debugLog.ipc(`SAVE_FILE called`); const result = await dialog.showSaveDialog(mainWindow, options); return { canceled: result.canceled, filePath: result.filePath, } satisfies IPCResponse<'dialog:saveFile'>; } ); } ``` --- ## Components Refactoring ### Priority Matrix | Priority | View | Lines | Action Required | | -------- | -------------- | ----- | ------------------------------------------------------ | | 🔴 P0 | spec-view | 1,230 | Create subfolder with components/, dialogs/, hooks/ | | 🔴 P0 | analysis-view | 1,134 | Create subfolder with components/, dialogs/, hooks/ | | 🔴 P0 | agent-view | 916 | Create subfolder, extract message list, input, sidebar | | 🟡 P1 | welcome-view | 815 | Create subfolder, extract sections | | 🟡 P1 | context-view | 735 | Create subfolder, extract components | | 🟡 P1 | terminal-view | 697 | Expand existing subfolder | | 🟡 P1 | interview-view | 637 | Create subfolder | | 🟢 P2 | settings-view | 178 | Move dialogs from components/ to dialogs/ | | ✅ Done | board-view | 685 | Already properly structured | | ✅ Done | setup-view | 144 | Already properly structured | | ✅ Done | profiles-view | 300 | Already properly structured | ### Immediate Dialog Reorganization ```bash # Settings-view: Move dialogs to proper location mv settings-view/components/keyboard-map-dialog.tsx → settings-view/dialogs/ mv settings-view/components/delete-project-dialog.tsx → settings-view/dialogs/ # Root components: Organize global dialogs mv components/dialogs/board-background-modal.tsx → board-view/dialogs/ # Agent-related dialogs: Move to agent-view mv components/delete-session-dialog.tsx → agent-view/dialogs/ mv components/delete-all-archived-sessions-dialog.tsx → agent-view/dialogs/ ``` --- ## Web + Electron Dual Support ### Platform Detection ```typescript // src/lib/platform.ts export const isElectron = typeof window !== 'undefined' && 'electronAPI' in window; export const platform = { isElectron, isWeb: !isElectron, isMac: isElectron ? window.electronAPI.platform === 'darwin' : false, isWindows: isElectron ? window.electronAPI.platform === 'win32' : false, isLinux: isElectron ? window.electronAPI.platform === 'linux' : false, }; ``` ### API Abstraction Layer ```typescript // src/lib/api/file-picker.ts import { platform } from '../platform'; export interface FilePickerResult { canceled: boolean; paths: string[]; } export async function pickDirectory(): Promise { if (platform.isElectron) { const result = await window.dialogAPI.openDirectory(); return { canceled: result.canceled, paths: result.filePaths || [] }; } // Web fallback using File System Access API try { const handle = await window.showDirectoryPicker(); return { canceled: false, paths: [handle.name] }; } catch (error) { if ((error as Error).name === 'AbortError') { return { canceled: true, paths: [] }; } throw error; } } export async function pickFile(options?: { accept?: Record; }): Promise { if (platform.isElectron) { const result = await window.dialogAPI.openFile({ filters: options?.accept ? Object.entries(options.accept).map(([name, extensions]) => ({ name, extensions, })) : undefined, }); return { canceled: result.canceled, paths: result.filePaths || [] }; } // Web fallback try { const [handle] = await window.showOpenFilePicker({ types: options?.accept ? Object.entries(options.accept).map(([description, accept]) => ({ description, accept: { 'application/*': accept }, })) : undefined, }); return { canceled: false, paths: [handle.name] }; } catch (error) { if ((error as Error).name === 'AbortError') { return { canceled: true, paths: [] }; } throw error; } } ``` --- ## Migration Phases ### Phase 1: Foundation (Week 1-2) **Goal**: Set up new build infrastructure without breaking existing functionality. - [x] Create `vite.config.mts` with electron plugins - [x] Create `electron/tsconfig.json` for Electron TypeScript - [x] Convert `electron/main.js` → `electron/main.ts` - [x] Convert `electron/preload.js` → `electron/preload.ts` - [ ] Implement IPC schema and type-safe handlers (deferred - using existing HTTP API) - [x] Set up TanStack Router configuration - [ ] Port debug console from starter template (deferred) - [x] Create `index.html` for Vite entry **Deliverables**: - [x] Working Vite dev server - [x] TypeScript Electron main process - [ ] Debug console functional (deferred) ### Phase 2: Core Migration (Week 3-4) **Goal**: Replace Next.js with Vite while maintaining feature parity. - [x] Create `src/renderer.tsx` entry point - [x] Create `src/App.tsx` root component - [x] Set up TanStack Router with file-based routes - [x] Port all views to route files - [x] Update environment variables (`NEXT_PUBLIC_*` → `VITE_*`) - [x] Verify Zustand stores work unchanged - [x] Verify HTTP API client works unchanged - [x] Test Electron build - [ ] Test Web build (needs verification) **Additional completed tasks**: - [x] Remove all "use client" directives (not needed in Vite) - [x] Replace all `setCurrentView()` calls with TanStack Router `navigate()` - [x] Rename `apps/app` to `apps/ui` - [x] Update package.json scripts - [x] Configure memory history for Electron (no URL bar) - [x] Fix ES module imports (replace `require()` with `import`) - [x] Remove PostCSS config (using `@tailwindcss/vite` plugin) **Deliverables**: - [x] All views accessible via TanStack Router - [x] Electron build functional - [ ] Web build functional (needs testing) - [x] No regression in existing functionality ### Phase 3: Component Refactoring (Week 5-7) **Goal**: Refactor large view files to follow folder-pattern.md. - [ ] Refactor `spec-view.tsx` (1,230 lines) - [ ] Refactor `analysis-view.tsx` (1,134 lines) - [ ] Refactor `agent-view.tsx` (916 lines) - [ ] Refactor `welcome-view.tsx` (815 lines) - [ ] Refactor `context-view.tsx` (735 lines) - [ ] Refactor `terminal-view.tsx` (697 lines) - [ ] Refactor `interview-view.tsx` (637 lines) - [ ] Reorganize `settings-view` dialogs **Deliverables**: - All views under 500 lines - Consistent folder structure across all views - Barrel exports for all component folders ### Phase 4: Package Extraction (Week 8) **Goal**: Create shared packages for better modularity. - [ ] Create `libs/types/` package - [ ] Create `libs/utils/` package - [ ] Create `libs/platform/` package - [ ] Create `libs/model-resolver/` package - [ ] Create `libs/ipc-types/` package - [ ] Update imports across apps **Deliverables**: - 5 new shared packages - No code duplication between apps - Clean dependency graph ### Phase 5: Polish & Testing (Week 9-10) **Goal**: Ensure production readiness. - [ ] Write E2E tests with Playwright - [ ] Performance benchmarking - [ ] Bundle size optimization - [ ] Documentation updates - [ ] CI/CD pipeline updates - [ ] Remove Next.js dependencies **Deliverables**: - Comprehensive test coverage - Performance metrics documentation - Updated CI/CD configuration - Clean package.json (no Next.js) --- ## Expected Benefits ### Developer Experience | Aspect | Before | After | | ---------------------- | ------------- | ------------------ | | Dev server startup | 8-15 seconds | 1-3 seconds | | Hot Module Replacement | 500ms-2s | 50-100ms | | TypeScript in Electron | Not supported | Full support | | Debug tooling | Limited | Full debug console | | Build times | 45-90 seconds | 15-30 seconds | ### Code Quality | Aspect | Before | After | | ---------------------- | ------------ | --------------------- | | Electron type safety | 0% | 100% | | Component organization | Inconsistent | Standardized | | Code sharing | None | 5 shared packages | | Path handling | Ad-hoc | Centralized utilities | ### Bundle Size | Aspect | Before | After | | ------------------ | ------- | ------- | | Next.js runtime | ~200KB | 0KB | | Framework overhead | High | Minimal | | Tree shaking | Limited | Full | --- ## Risk Mitigation ### Rollback Strategy 1. **Branch-based development**: All work on feature branch 2. **Parallel running**: Keep Next.js functional until migration complete 3. **Feature flags**: Toggle between old/new implementations 4. **Comprehensive testing**: E2E tests before/after comparison ### Known Challenges | Challenge | Mitigation | | --------------------- | ------------------------------------------------ | | Route migration | TanStack Router has similar file-based routing | | Environment variables | Simple search/replace (`NEXT_PUBLIC_` → `VITE_`) | | Build configuration | Reference electron-starter-template | | SSR considerations | N/A - we don't use SSR | ### Testing Strategy 1. **Unit tests**: Vitest for component/utility testing 2. **Integration tests**: Test IPC communication 3. **E2E tests**: Playwright for full application testing 4. **Manual testing**: QA checklist for each view --- ## Appendix: Vite Configuration Reference ```typescript // vite.config.mts import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import electron from 'vite-plugin-electron'; import renderer from 'vite-plugin-electron-renderer'; import { TanStackRouterVite } from '@tanstack/router-plugin/vite'; import tailwindcss from '@tailwindcss/vite'; import path from 'path'; export default defineConfig({ plugins: [ react({ babel: { plugins: [['babel-plugin-react-compiler', {}]], }, }), TanStackRouterVite({ routesDirectory: './src/routes', generatedRouteTree: './src/routeTree.gen.ts', autoCodeSplitting: true, }), tailwindcss(), electron([ { entry: 'electron/main.ts', vite: { build: { outDir: 'dist-electron', rollupOptions: { external: ['electron'], }, }, }, }, { entry: 'electron/preload.ts', onstart: ({ reload }) => reload(), vite: { build: { outDir: 'dist-electron', rollupOptions: { external: ['electron'], }, }, }, }, ]), renderer(), ], resolve: { alias: { '@': path.resolve(__dirname, 'src'), '@electron': path.resolve(__dirname, 'electron'), }, }, build: { outDir: 'dist', }, }); ``` --- ## Document History | Version | Date | Author | Changes | | ------- | -------- | ------ | ------------------------------------------------------------------------------------- | | 1.0 | Dec 2025 | Team | Initial migration plan | | 1.1 | Dec 2025 | Team | Phase 1 & 2 complete. Updated checkboxes, added completed tasks, noted deferred items | --- **Next Steps**: 1. ~~Review and approve this plan~~ ✅ 2. ~~Wait for `feature/worktrees` branch merge~~ ✅ 3. ~~Create migration branch~~ ✅ (refactor/frontend) 4. ~~Complete Phase 1 implementation~~ ✅ 5. ~~Complete Phase 2 implementation~~ ✅ 6. **Current**: Verify web build works, then begin Phase 3 (Component Refactoring) 7. Consider implementing deferred items: Debug console, IPC schema