# 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