diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index f763c08d..ce66ac9e 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -67,6 +67,7 @@ import { createPipelineRoutes } from './routes/pipeline/index.js'; import { pipelineService } from './services/pipeline-service.js'; import { createIdeationRoutes } from './routes/ideation/index.js'; import { IdeationService } from './services/ideation-service.js'; +import { getDevServerService } from './services/dev-server-service.js'; // Load environment variables dotenv.config(); @@ -176,6 +177,10 @@ const codexUsageService = new CodexUsageService(codexAppServerService); const mcpTestService = new MCPTestService(settingsService); const ideationService = new IdeationService(events, settingsService, featureLoader); +// Initialize DevServerService with event emitter for real-time log streaming +const devServerService = getDevServerService(); +devServerService.setEventEmitter(events); + // Initialize services (async () => { await agentService.initialize(); diff --git a/apps/server/src/middleware/validate-paths.ts b/apps/server/src/middleware/validate-paths.ts index 51b8ccb1..1f7f3876 100644 --- a/apps/server/src/middleware/validate-paths.ts +++ b/apps/server/src/middleware/validate-paths.ts @@ -8,12 +8,28 @@ import type { Request, Response, NextFunction } from 'express'; import { validatePath, PathNotAllowedError } from '@automaker/platform'; /** - * Creates a middleware that validates specified path parameters in req.body + * Helper to get parameter value from request (checks body first, then query) + */ +function getParamValue(req: Request, paramName: string): unknown { + // Check body first (for POST/PUT/PATCH requests) + if (req.body && req.body[paramName] !== undefined) { + return req.body[paramName]; + } + // Fall back to query params (for GET requests) + if (req.query && req.query[paramName] !== undefined) { + return req.query[paramName]; + } + return undefined; +} + +/** + * Creates a middleware that validates specified path parameters in req.body or req.query * @param paramNames - Names of parameters to validate (e.g., 'projectPath', 'worktreePath') * @example * router.post('/create', validatePathParams('projectPath'), handler); * router.post('/delete', validatePathParams('projectPath', 'worktreePath'), handler); * router.post('/send', validatePathParams('workingDirectory?', 'imagePaths[]'), handler); + * router.get('/logs', validatePathParams('worktreePath'), handler); // Works with query params too * * Special syntax: * - 'paramName?' - Optional parameter (only validated if present) @@ -26,8 +42,8 @@ export function validatePathParams(...paramNames: string[]) { // Handle optional parameters (paramName?) if (paramName.endsWith('?')) { const actualName = paramName.slice(0, -1); - const value = req.body[actualName]; - if (value) { + const value = getParamValue(req, actualName); + if (value && typeof value === 'string') { validatePath(value); } continue; @@ -36,18 +52,20 @@ export function validatePathParams(...paramNames: string[]) { // Handle array parameters (paramName[]) if (paramName.endsWith('[]')) { const actualName = paramName.slice(0, -2); - const values = req.body[actualName]; + const values = getParamValue(req, actualName); if (Array.isArray(values) && values.length > 0) { for (const value of values) { - validatePath(value); + if (typeof value === 'string') { + validatePath(value); + } } } continue; } // Handle regular parameters - const value = req.body[paramName]; - if (value) { + const value = getParamValue(req, paramName); + if (value && typeof value === 'string') { validatePath(value); } } diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index a00e0bfe..525c3a96 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -33,6 +33,7 @@ import { createMigrateHandler } from './routes/migrate.js'; import { createStartDevHandler } from './routes/start-dev.js'; import { createStopDevHandler } from './routes/stop-dev.js'; import { createListDevServersHandler } from './routes/list-dev-servers.js'; +import { createGetDevServerLogsHandler } from './routes/dev-server-logs.js'; import { createGetInitScriptHandler, createPutInitScriptHandler, @@ -97,6 +98,11 @@ export function createWorktreeRoutes(events: EventEmitter): Router { ); router.post('/stop-dev', createStopDevHandler()); router.post('/list-dev-servers', createListDevServersHandler()); + router.get( + '/dev-server-logs', + validatePathParams('worktreePath'), + createGetDevServerLogsHandler() + ); // Init script routes router.get('/init-script', createGetInitScriptHandler()); diff --git a/apps/server/src/routes/worktree/routes/dev-server-logs.ts b/apps/server/src/routes/worktree/routes/dev-server-logs.ts new file mode 100644 index 00000000..66dfed92 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/dev-server-logs.ts @@ -0,0 +1,52 @@ +/** + * GET /dev-server-logs endpoint - Get buffered logs for a worktree's dev server + * + * Returns the scrollback buffer containing historical log output for a running + * dev server. Used by clients to populate the log panel on initial connection + * before subscribing to real-time updates via WebSocket. + */ + +import type { Request, Response } from 'express'; +import { getDevServerService } from '../../../services/dev-server-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createGetDevServerLogsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.query as { + worktreePath?: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath query parameter is required', + }); + return; + } + + const devServerService = getDevServerService(); + const result = devServerService.getServerLogs(worktreePath); + + if (result.success && result.result) { + res.json({ + success: true, + result: { + worktreePath: result.result.worktreePath, + port: result.result.port, + logs: result.result.logs, + startedAt: result.result.startedAt, + }, + }); + } else { + res.status(404).json({ + success: false, + error: result.error || 'Failed to get dev server logs', + }); + } + } catch (error) { + logError(error, 'Get dev server logs failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts index cac27e92..5187c0c8 100644 --- a/apps/server/src/services/dev-server-service.ts +++ b/apps/server/src/services/dev-server-service.ts @@ -12,24 +12,123 @@ import * as secureFs from '../lib/secure-fs.js'; import path from 'path'; import net from 'net'; import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../lib/events.js'; const logger = createLogger('DevServerService'); +// Maximum scrollback buffer size (characters) - matches TerminalService pattern +const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server + +// Throttle output to prevent overwhelming WebSocket under heavy load +const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback +const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency + export interface DevServerInfo { worktreePath: string; port: number; url: string; process: ChildProcess | null; startedAt: Date; + // Scrollback buffer for log history (replay on reconnect) + scrollbackBuffer: string; + // Pending output to be flushed to subscribers + outputBuffer: string; + // Throttle timer for batching output + flushTimeout: NodeJS.Timeout | null; + // Flag to indicate server is stopping (prevents output after stop) + stopping: boolean; } // Port allocation starts at 3001 to avoid conflicts with common dev ports const BASE_PORT = 3001; const MAX_PORT = 3099; // Safety limit +// Common livereload ports that may need cleanup when stopping dev servers +const LIVERELOAD_PORTS = [35729, 35730, 35731] as const; + class DevServerService { private runningServers: Map = new Map(); private allocatedPorts: Set = new Set(); + private emitter: EventEmitter | null = null; + + /** + * Set the event emitter for streaming log events + * Called during service initialization with the global event emitter + */ + setEventEmitter(emitter: EventEmitter): void { + this.emitter = emitter; + } + + /** + * Append data to scrollback buffer with size limit enforcement + * Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE + */ + private appendToScrollback(server: DevServerInfo, data: string): void { + server.scrollbackBuffer += data; + if (server.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) { + server.scrollbackBuffer = server.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE); + } + } + + /** + * Flush buffered output to WebSocket subscribers + * Sends batched output to prevent overwhelming clients under heavy load + */ + private flushOutput(server: DevServerInfo): void { + // Skip flush if server is stopping or buffer is empty + if (server.stopping || server.outputBuffer.length === 0) { + server.flushTimeout = null; + return; + } + + let dataToSend = server.outputBuffer; + if (dataToSend.length > OUTPUT_BATCH_SIZE) { + // Send in batches if buffer is large + dataToSend = server.outputBuffer.slice(0, OUTPUT_BATCH_SIZE); + server.outputBuffer = server.outputBuffer.slice(OUTPUT_BATCH_SIZE); + // Schedule another flush for remaining data + server.flushTimeout = setTimeout(() => this.flushOutput(server), OUTPUT_THROTTLE_MS); + } else { + server.outputBuffer = ''; + server.flushTimeout = null; + } + + // Emit output event for WebSocket streaming + if (this.emitter) { + this.emitter.emit('dev-server:output', { + worktreePath: server.worktreePath, + content: dataToSend, + timestamp: new Date().toISOString(), + }); + } + } + + /** + * Handle incoming stdout/stderr data from dev server process + * Buffers data for scrollback replay and schedules throttled emission + */ + private handleProcessOutput(server: DevServerInfo, data: Buffer): void { + // Skip output if server is stopping + if (server.stopping) { + return; + } + + const content = data.toString(); + + // Append to scrollback buffer for replay on reconnect + this.appendToScrollback(server, content); + + // Buffer output for throttled live delivery + server.outputBuffer += content; + + // Schedule flush if not already scheduled + if (!server.flushTimeout) { + server.flushTimeout = setTimeout(() => this.flushOutput(server), OUTPUT_THROTTLE_MS); + } + + // Also log for debugging (existing behavior) + logger.debug(`[Port${server.port}] ${content.trim()}`); + } /** * Check if a port is available (not in use by system or by us) @@ -244,10 +343,9 @@ class DevServerService { // Reserve the port (port was already force-killed in findAvailablePort) this.allocatedPorts.add(port); - // Also kill common related ports (livereload uses 35729 by default) + // Also kill common related ports (livereload, etc.) // Some dev servers use fixed ports for HMR/livereload regardless of main port - const commonRelatedPorts = [35729, 35730, 35731]; - for (const relatedPort of commonRelatedPorts) { + for (const relatedPort of LIVERELOAD_PORTS) { this.killProcessOnPort(relatedPort); } @@ -259,9 +357,14 @@ class DevServerService { logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`); // Spawn the dev process with PORT environment variable + // FORCE_COLOR enables colored output even when not running in a TTY const env = { ...process.env, PORT: String(port), + FORCE_COLOR: '1', + // Some tools use these additional env vars for color detection + COLORTERM: 'truecolor', + TERM: 'xterm-256color', }; const devProcess = spawn(devCommand.cmd, devCommand.args, { @@ -274,32 +377,66 @@ class DevServerService { // Track if process failed early using object to work around TypeScript narrowing const status = { error: null as string | null, exited: false }; - // Log output for debugging + // Create server info early so we can reference it in handlers + // We'll add it to runningServers after verifying the process started successfully + const serverInfo: DevServerInfo = { + worktreePath, + port, + url: `http://localhost:${port}`, + process: devProcess, + startedAt: new Date(), + scrollbackBuffer: '', + outputBuffer: '', + flushTimeout: null, + stopping: false, + }; + + // Capture stdout with buffer management and event emission if (devProcess.stdout) { devProcess.stdout.on('data', (data: Buffer) => { - logger.debug(`[Port${port}] ${data.toString().trim()}`); + this.handleProcessOutput(serverInfo, data); }); } + // Capture stderr with buffer management and event emission if (devProcess.stderr) { devProcess.stderr.on('data', (data: Buffer) => { - const msg = data.toString().trim(); - logger.debug(`[Port${port}] ${msg}`); + this.handleProcessOutput(serverInfo, data); }); } + // Helper to clean up resources and emit stop event + const cleanupAndEmitStop = (exitCode: number | null, errorMessage?: string) => { + if (serverInfo.flushTimeout) { + clearTimeout(serverInfo.flushTimeout); + serverInfo.flushTimeout = null; + } + + // Emit stopped event (only if not already stopping - prevents duplicate events) + if (this.emitter && !serverInfo.stopping) { + this.emitter.emit('dev-server:stopped', { + worktreePath, + port, + exitCode, + error: errorMessage, + timestamp: new Date().toISOString(), + }); + } + + this.allocatedPorts.delete(port); + this.runningServers.delete(worktreePath); + }; + devProcess.on('error', (error) => { logger.error(`Process error:`, error); status.error = error.message; - this.allocatedPorts.delete(port); - this.runningServers.delete(worktreePath); + cleanupAndEmitStop(null, error.message); }); devProcess.on('exit', (code) => { logger.info(`Process for ${worktreePath} exited with code ${code}`); status.exited = true; - this.allocatedPorts.delete(port); - this.runningServers.delete(worktreePath); + cleanupAndEmitStop(code); }); // Wait a moment to see if the process fails immediately @@ -319,16 +456,19 @@ class DevServerService { }; } - const serverInfo: DevServerInfo = { - worktreePath, - port, - url: `http://localhost:${port}`, - process: devProcess, - startedAt: new Date(), - }; - + // Server started successfully - add to running servers map this.runningServers.set(worktreePath, serverInfo); + // Emit started event for WebSocket subscribers + if (this.emitter) { + this.emitter.emit('dev-server:started', { + worktreePath, + port, + url: serverInfo.url, + timestamp: new Date().toISOString(), + }); + } + return { success: true, result: { @@ -365,6 +505,28 @@ class DevServerService { logger.info(`Stopping dev server for ${worktreePath}`); + // Mark as stopping to prevent further output events + server.stopping = true; + + // Clean up flush timeout to prevent memory leaks + if (server.flushTimeout) { + clearTimeout(server.flushTimeout); + server.flushTimeout = null; + } + + // Clear any pending output buffer + server.outputBuffer = ''; + + // Emit stopped event immediately so UI updates right away + if (this.emitter) { + this.emitter.emit('dev-server:stopped', { + worktreePath, + port: server.port, + exitCode: null, // Will be populated by exit handler if process exits normally + timestamp: new Date().toISOString(), + }); + } + // Kill the process if (server.process && !server.process.killed) { server.process.kill('SIGTERM'); @@ -422,6 +584,41 @@ class DevServerService { return this.runningServers.get(worktreePath); } + /** + * Get buffered logs for a worktree's dev server + * Returns the scrollback buffer containing historical log output + * Used by the API to serve logs to clients on initial connection + */ + getServerLogs(worktreePath: string): { + success: boolean; + result?: { + worktreePath: string; + port: number; + logs: string; + startedAt: string; + }; + error?: string; + } { + const server = this.runningServers.get(worktreePath); + + if (!server) { + return { + success: false, + error: `No dev server running for worktree: ${worktreePath}`, + }; + } + + return { + success: true, + result: { + worktreePath: server.worktreePath, + port: server.port, + logs: server.scrollbackBuffer, + startedAt: server.startedAt.toISOString(), + }, + }; + } + /** * Get all allocated ports */ diff --git a/apps/ui/src/components/ui/xterm-log-viewer.tsx b/apps/ui/src/components/ui/xterm-log-viewer.tsx new file mode 100644 index 00000000..7c6cc7b4 --- /dev/null +++ b/apps/ui/src/components/ui/xterm-log-viewer.tsx @@ -0,0 +1,300 @@ +import { useEffect, useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react'; +import { useAppStore } from '@/store/app-store'; +import { getTerminalTheme, DEFAULT_TERMINAL_FONT } from '@/config/terminal-themes'; + +// Types for dynamically imported xterm modules +type XTerminal = InstanceType; +type XFitAddon = InstanceType; + +export interface XtermLogViewerRef { + /** Append content to the log viewer */ + append: (content: string) => void; + /** Clear all content */ + clear: () => void; + /** Scroll to the bottom */ + scrollToBottom: () => void; + /** Write content (replaces all content) */ + write: (content: string) => void; +} + +export interface XtermLogViewerProps { + /** Initial content to display */ + initialContent?: string; + /** Font size in pixels (default: 13) */ + fontSize?: number; + /** Whether to auto-scroll to bottom when new content is added (default: true) */ + autoScroll?: boolean; + /** Custom class name for the container */ + className?: string; + /** Minimum height for the container */ + minHeight?: number; + /** Callback when user scrolls away from bottom */ + onScrollAwayFromBottom?: () => void; + /** Callback when user scrolls to bottom */ + onScrollToBottom?: () => void; +} + +/** + * A read-only terminal log viewer using xterm.js for perfect ANSI color rendering. + * Use this component when you need to display terminal output with ANSI escape codes. + */ +export const XtermLogViewer = forwardRef( + ( + { + initialContent, + fontSize = 13, + autoScroll = true, + className, + minHeight = 300, + onScrollAwayFromBottom, + onScrollToBottom, + }, + ref + ) => { + const containerRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef(null); + const [isReady, setIsReady] = useState(false); + const autoScrollRef = useRef(autoScroll); + const pendingContentRef = useRef([]); + + // Get theme from store + const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme); + const effectiveTheme = getEffectiveTheme(); + + // Track system dark mode for "system" theme + const [systemIsDark, setSystemIsDark] = useState(() => { + if (typeof window !== 'undefined') { + return window.matchMedia('(prefers-color-scheme: dark)').matches; + } + return true; + }); + + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = (e: MediaQueryListEvent) => setSystemIsDark(e.matches); + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + const resolvedTheme = + effectiveTheme === 'system' ? (systemIsDark ? 'dark' : 'light') : effectiveTheme; + + // Update autoScroll ref when prop changes + useEffect(() => { + autoScrollRef.current = autoScroll; + }, [autoScroll]); + + // Initialize xterm + useEffect(() => { + if (!containerRef.current) return; + + let mounted = true; + + const initTerminal = async () => { + const [{ Terminal }, { FitAddon }] = await Promise.all([ + import('@xterm/xterm'), + import('@xterm/addon-fit'), + ]); + await import('@xterm/xterm/css/xterm.css'); + + if (!mounted || !containerRef.current) return; + + const terminalTheme = getTerminalTheme(resolvedTheme); + + const terminal = new Terminal({ + cursorBlink: false, + cursorStyle: 'underline', + cursorInactiveStyle: 'none', + fontSize, + fontFamily: DEFAULT_TERMINAL_FONT, + lineHeight: 1.2, + theme: terminalTheme, + disableStdin: true, // Read-only mode + scrollback: 10000, + convertEol: true, + }); + + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + + terminal.open(containerRef.current); + + // Try to load WebGL addon for better performance + try { + const { WebglAddon } = await import('@xterm/addon-webgl'); + const webglAddon = new WebglAddon(); + webglAddon.onContextLoss(() => webglAddon.dispose()); + terminal.loadAddon(webglAddon); + } catch { + // WebGL not available, continue with canvas renderer + } + + // Wait for layout to stabilize then fit + requestAnimationFrame(() => { + if (mounted && containerRef.current) { + try { + fitAddon.fit(); + } catch { + // Ignore fit errors during initialization + } + } + }); + + xtermRef.current = terminal; + fitAddonRef.current = fitAddon; + setIsReady(true); + + // Write initial content if provided + if (initialContent) { + terminal.write(initialContent); + } + + // Write any pending content that was queued before terminal was ready + if (pendingContentRef.current.length > 0) { + pendingContentRef.current.forEach((content) => terminal.write(content)); + pendingContentRef.current = []; + } + }; + + initTerminal(); + + return () => { + mounted = false; + if (xtermRef.current) { + xtermRef.current.dispose(); + xtermRef.current = null; + } + fitAddonRef.current = null; + setIsReady(false); + }; + }, []); // Only run once on mount + + // Update theme when it changes + useEffect(() => { + if (xtermRef.current && isReady) { + const terminalTheme = getTerminalTheme(resolvedTheme); + xtermRef.current.options.theme = terminalTheme; + } + }, [resolvedTheme, isReady]); + + // Update font size when it changes + useEffect(() => { + if (xtermRef.current && isReady) { + xtermRef.current.options.fontSize = fontSize; + fitAddonRef.current?.fit(); + } + }, [fontSize, isReady]); + + // Handle resize + useEffect(() => { + if (!containerRef.current || !isReady) return; + + const handleResize = () => { + if (fitAddonRef.current && containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + try { + fitAddonRef.current.fit(); + } catch { + // Ignore fit errors + } + } + } + }; + + const resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(containerRef.current); + window.addEventListener('resize', handleResize); + + return () => { + resizeObserver.disconnect(); + window.removeEventListener('resize', handleResize); + }; + }, [isReady]); + + // Monitor scroll position + useEffect(() => { + if (!isReady || !containerRef.current) return; + + const viewport = containerRef.current.querySelector('.xterm-viewport') as HTMLElement | null; + if (!viewport) return; + + const checkScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = viewport; + const isAtBottom = scrollHeight - scrollTop - clientHeight <= 10; + + if (isAtBottom) { + autoScrollRef.current = true; + onScrollToBottom?.(); + } else { + autoScrollRef.current = false; + onScrollAwayFromBottom?.(); + } + }; + + viewport.addEventListener('scroll', checkScroll, { passive: true }); + return () => viewport.removeEventListener('scroll', checkScroll); + }, [isReady, onScrollAwayFromBottom, onScrollToBottom]); + + // Expose methods via ref + const append = useCallback((content: string) => { + if (xtermRef.current) { + xtermRef.current.write(content); + if (autoScrollRef.current) { + xtermRef.current.scrollToBottom(); + } + } else { + // Queue content if terminal isn't ready yet + pendingContentRef.current.push(content); + } + }, []); + + const clear = useCallback(() => { + if (xtermRef.current) { + xtermRef.current.clear(); + } + }, []); + + const scrollToBottom = useCallback(() => { + if (xtermRef.current) { + xtermRef.current.scrollToBottom(); + autoScrollRef.current = true; + } + }, []); + + const write = useCallback((content: string) => { + if (xtermRef.current) { + xtermRef.current.reset(); + xtermRef.current.write(content); + if (autoScrollRef.current) { + xtermRef.current.scrollToBottom(); + } + } else { + pendingContentRef.current = [content]; + } + }, []); + + useImperativeHandle(ref, () => ({ + append, + clear, + scrollToBottom, + write, + })); + + const terminalTheme = getTerminalTheme(resolvedTheme); + + return ( +
+ ); + } +); + +XtermLogViewer.displayName = 'XtermLogViewer'; diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx new file mode 100644 index 00000000..859ad34c --- /dev/null +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx @@ -0,0 +1,289 @@ +import { useEffect, useRef, useCallback, useState } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { + Loader2, + Terminal, + ArrowDown, + ExternalLink, + Square, + RefreshCw, + AlertCircle, + Clock, + GitBranch, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer'; +import { useDevServerLogs } from '../hooks/use-dev-server-logs'; +import type { WorktreeInfo } from '../types'; + +interface DevServerLogsPanelProps { + /** Whether the panel is open */ + open: boolean; + /** Callback when the panel is closed */ + onClose: () => void; + /** The worktree to show logs for */ + worktree: WorktreeInfo | null; + /** Callback to stop the dev server */ + onStopDevServer?: (worktree: WorktreeInfo) => void; + /** Callback to open the dev server URL in browser */ + onOpenDevServerUrl?: (worktree: WorktreeInfo) => void; +} + +/** + * Panel component for displaying dev server logs with ANSI color rendering + * and auto-scroll functionality. + * + * Features: + * - Real-time log streaming via WebSocket + * - Full ANSI color code rendering via xterm.js + * - Auto-scroll to bottom (can be paused by scrolling up) + * - Server status indicators + * - Quick actions (stop server, open in browser) + */ +export function DevServerLogsPanel({ + open, + onClose, + worktree, + onStopDevServer, + onOpenDevServerUrl, +}: DevServerLogsPanelProps) { + const xtermRef = useRef(null); + const [autoScrollEnabled, setAutoScrollEnabled] = useState(true); + const lastLogsLengthRef = useRef(0); + const lastWorktreePathRef = useRef(null); + + const { + logs, + isRunning, + isLoading, + error, + port, + url, + startedAt, + exitCode, + serverError, + fetchLogs, + } = useDevServerLogs({ + worktreePath: open ? (worktree?.path ?? null) : null, + autoSubscribe: open, + }); + + // Write logs to xterm when they change + useEffect(() => { + if (!xtermRef.current || !logs) return; + + // If worktree changed, reset the terminal and write all content + if (lastWorktreePathRef.current !== worktree?.path) { + lastWorktreePathRef.current = worktree?.path ?? null; + lastLogsLengthRef.current = 0; + xtermRef.current.write(logs); + lastLogsLengthRef.current = logs.length; + return; + } + + // If logs got shorter (e.g., cleared), rewrite all + if (logs.length < lastLogsLengthRef.current) { + xtermRef.current.write(logs); + lastLogsLengthRef.current = logs.length; + return; + } + + // Append only the new content + if (logs.length > lastLogsLengthRef.current) { + const newContent = logs.slice(lastLogsLengthRef.current); + xtermRef.current.append(newContent); + lastLogsLengthRef.current = logs.length; + } + }, [logs, worktree?.path]); + + // Reset when panel opens with a new worktree + useEffect(() => { + if (open) { + setAutoScrollEnabled(true); + if (worktree?.path !== lastWorktreePathRef.current) { + lastLogsLengthRef.current = 0; + } + } + }, [open, worktree?.path]); + + // Scroll to bottom handler + const scrollToBottom = useCallback(() => { + xtermRef.current?.scrollToBottom(); + setAutoScrollEnabled(true); + }, []); + + // Format the started time + const formatStartedAt = useCallback((timestamp: string | null) => { + if (!timestamp) return null; + try { + const date = new Date(timestamp); + return date.toLocaleTimeString(); + } catch { + return null; + } + }, []); + + if (!worktree) return null; + + const formattedStartTime = formatStartedAt(startedAt); + const lineCount = logs ? logs.split('\n').length : 0; + + return ( + !isOpen && onClose()}> + + {/* Compact Header */} + +
+ + + Dev Server + {isRunning ? ( + + + Running + + ) : exitCode !== null ? ( + + + Stopped ({exitCode}) + + ) : null} + +
+ {isRunning && url && onOpenDevServerUrl && ( + + )} + {isRunning && onStopDevServer && ( + + )} + +
+
+ + {/* Info bar - more compact */} +
+ + + {worktree.branch} + + {port && ( + + Port + {port} + + )} + {formattedStartTime && ( + + + {formattedStartTime} + + )} +
+
+ + {/* Error displays - inline */} + {(error || serverError) && ( +
+ {error && ( +
+ + {error} +
+ )} + {serverError && ( +
+ + Server error: {serverError} +
+ )} +
+ )} + + {/* Log content area - fills remaining space */} +
+ {isLoading && !logs ? ( +
+ + Loading logs... +
+ ) : !logs && !isRunning ? ( +
+ +

No dev server running

+

Start a dev server to see logs here

+
+ ) : !logs ? ( +
+
+

Waiting for output...

+

+ Logs will appear as the server generates output +

+
+ ) : ( + setAutoScrollEnabled(false)} + onScrollToBottom={() => setAutoScrollEnabled(true)} + /> + )} +
+ + {/* Footer status bar */} +
+ {lineCount > 0 ? `${lineCount} lines` : 'No output'} + {!autoScrollEnabled && logs && ( + + )} + {autoScrollEnabled && logs && ( + + + Auto-scroll + + )} +
+ +
+ ); +} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/index.ts b/apps/ui/src/components/views/board-view/worktree-panel/components/index.ts index b436fa26..719653f3 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/index.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/index.ts @@ -1,3 +1,4 @@ export { BranchSwitchDropdown } from './branch-switch-dropdown'; +export { DevServerLogsPanel } from './dev-server-logs-panel'; export { WorktreeActionsDropdown } from './worktree-actions-dropdown'; export { WorktreeTab } from './worktree-tab'; diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index c7d8f26b..e13a503a 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -25,6 +25,7 @@ import { AlertCircle, RefreshCw, Copy, + ScrollText, } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; @@ -56,6 +57,7 @@ interface WorktreeActionsDropdownProps { onStartDevServer: (worktree: WorktreeInfo) => void; onStopDevServer: (worktree: WorktreeInfo) => void; onOpenDevServerUrl: (worktree: WorktreeInfo) => void; + onViewDevServerLogs: (worktree: WorktreeInfo) => void; onRunInitScript: (worktree: WorktreeInfo) => void; hasInitScript: boolean; } @@ -83,6 +85,7 @@ export function WorktreeActionsDropdown({ onStartDevServer, onStopDevServer, onOpenDevServerUrl, + onViewDevServerLogs, onRunInitScript, hasInitScript, }: WorktreeActionsDropdownProps) { @@ -147,6 +150,10 @@ export function WorktreeActionsDropdown({ Open in Browser + onViewDevServerLogs(worktree)} className="text-xs"> + + View Logs + onStopDevServer(worktree)} className="text-xs text-destructive focus:text-destructive" diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index 3945cf47..825502a2 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -1,4 +1,4 @@ -// @ts-nocheck +import type { JSX } from 'react'; import { Button } from '@/components/ui/button'; import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -45,6 +45,7 @@ interface WorktreeTabProps { onStartDevServer: (worktree: WorktreeInfo) => void; onStopDevServer: (worktree: WorktreeInfo) => void; onOpenDevServerUrl: (worktree: WorktreeInfo) => void; + onViewDevServerLogs: (worktree: WorktreeInfo) => void; onRunInitScript: (worktree: WorktreeInfo) => void; hasInitScript: boolean; } @@ -87,6 +88,7 @@ export function WorktreeTab({ onStartDevServer, onStopDevServer, onOpenDevServerUrl, + onViewDevServerLogs, onRunInitScript, hasInitScript, }: WorktreeTabProps) { @@ -337,6 +339,7 @@ export function WorktreeTab({ onStartDevServer={onStartDevServer} onStopDevServer={onStopDevServer} onOpenDevServerUrl={onOpenDevServerUrl} + onViewDevServerLogs={onViewDevServerLogs} onRunInitScript={onRunInitScript} hasInitScript={hasInitScript} /> diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/index.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/index.ts index a4f95af9..4f7c0c6a 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/index.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/index.ts @@ -1,5 +1,6 @@ export { useWorktrees } from './use-worktrees'; export { useDevServers } from './use-dev-servers'; +export { useDevServerLogs } from './use-dev-server-logs'; export { useBranches } from './use-branches'; export { useWorktreeActions } from './use-worktree-actions'; export { useRunningFeatures } from './use-running-features'; diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-server-logs.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-server-logs.ts new file mode 100644 index 00000000..d5472472 --- /dev/null +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-server-logs.ts @@ -0,0 +1,221 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { createLogger } from '@automaker/utils/logger'; +import { getElectronAPI } from '@/lib/electron'; +import { pathsEqual } from '@/lib/utils'; + +const logger = createLogger('DevServerLogs'); + +export interface DevServerLogState { + /** The log content (buffered + live) */ + logs: string; + /** Whether the server is currently running */ + isRunning: boolean; + /** Whether initial logs are being fetched */ + isLoading: boolean; + /** Error message if fetching logs failed */ + error: string | null; + /** Server port (if running) */ + port: number | null; + /** Server URL (if running) */ + url: string | null; + /** Timestamp when the server started */ + startedAt: string | null; + /** Exit code (if server stopped) */ + exitCode: number | null; + /** Error message from server (if stopped with error) */ + serverError: string | null; +} + +interface UseDevServerLogsOptions { + /** Path to the worktree to monitor logs for */ + worktreePath: string | null; + /** Whether to automatically subscribe to log events (default: true) */ + autoSubscribe?: boolean; +} + +/** + * Hook to subscribe to dev server log events and manage log state. + * + * This hook: + * 1. Fetches initial buffered logs from the server + * 2. Subscribes to WebSocket events for real-time log streaming + * 3. Handles server started/stopped events + * 4. Provides log state for rendering in a panel + * + * @example + * ```tsx + * const { logs, isRunning, isLoading } = useDevServerLogs({ + * worktreePath: '/path/to/worktree' + * }); + * ``` + */ +export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevServerLogsOptions) { + const [state, setState] = useState({ + logs: '', + isRunning: false, + isLoading: false, + error: null, + port: null, + url: null, + startedAt: null, + exitCode: null, + serverError: null, + }); + + // Keep track of whether we've fetched initial logs + const hasFetchedInitialLogs = useRef(false); + + /** + * Fetch buffered logs from the server + */ + const fetchLogs = useCallback(async () => { + if (!worktreePath) return; + + setState((prev) => ({ ...prev, isLoading: true, error: null })); + + try { + const api = getElectronAPI(); + if (!api?.worktree?.getDevServerLogs) { + setState((prev) => ({ + ...prev, + isLoading: false, + error: 'Dev server logs API not available', + })); + return; + } + + const result = await api.worktree.getDevServerLogs(worktreePath); + + if (result.success && result.result) { + setState((prev) => ({ + ...prev, + logs: result.result!.logs, + isRunning: true, + isLoading: false, + port: result.result!.port, + url: `http://localhost:${result.result!.port}`, + startedAt: result.result!.startedAt, + error: null, + })); + hasFetchedInitialLogs.current = true; + } else { + // Server might not be running - this is not necessarily an error + setState((prev) => ({ + ...prev, + isLoading: false, + isRunning: false, + error: result.error || null, + })); + } + } catch (error) { + logger.error('Failed to fetch dev server logs:', error); + setState((prev) => ({ + ...prev, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch logs', + })); + } + }, [worktreePath]); + + /** + * Clear logs and reset state + */ + const clearLogs = useCallback(() => { + setState({ + logs: '', + isRunning: false, + isLoading: false, + error: null, + port: null, + url: null, + startedAt: null, + exitCode: null, + serverError: null, + }); + hasFetchedInitialLogs.current = false; + }, []); + + /** + * Append content to logs + */ + const appendLogs = useCallback((content: string) => { + setState((prev) => ({ + ...prev, + logs: prev.logs + content, + })); + }, []); + + // Fetch initial logs when worktreePath changes + useEffect(() => { + if (worktreePath && autoSubscribe) { + hasFetchedInitialLogs.current = false; + fetchLogs(); + } else { + clearLogs(); + } + }, [worktreePath, autoSubscribe, fetchLogs, clearLogs]); + + // Subscribe to WebSocket events + useEffect(() => { + if (!worktreePath || !autoSubscribe) return; + + const api = getElectronAPI(); + if (!api?.worktree?.onDevServerLogEvent) { + logger.warn('Dev server log event API not available'); + return; + } + + const unsubscribe = api.worktree.onDevServerLogEvent((event) => { + // Filter events to only handle those for our worktree + if (!pathsEqual(event.payload.worktreePath, worktreePath)) return; + + switch (event.type) { + case 'dev-server:started': { + const { payload } = event; + logger.info('Dev server started:', payload); + setState((prev) => ({ + ...prev, + isRunning: true, + port: payload.port, + url: payload.url, + startedAt: payload.timestamp, + exitCode: null, + serverError: null, + // Clear logs on restart + logs: '', + })); + hasFetchedInitialLogs.current = false; + break; + } + case 'dev-server:output': { + const { payload } = event; + // Append the new output to existing logs + if (payload.content) { + appendLogs(payload.content); + } + break; + } + case 'dev-server:stopped': { + const { payload } = event; + logger.info('Dev server stopped:', payload); + setState((prev) => ({ + ...prev, + isRunning: false, + exitCode: payload.exitCode, + serverError: payload.error ?? null, + })); + break; + } + } + }); + + return unsubscribe; + }, [worktreePath, autoSubscribe, appendLogs]); + + return { + ...state, + fetchLogs, + clearLogs, + appendLogs, + }; +} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index a9f2431e..61733dfe 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -12,7 +12,7 @@ import { useWorktreeActions, useRunningFeatures, } from './hooks'; -import { WorktreeTab } from './components'; +import { WorktreeTab, DevServerLogsPanel } from './components'; export function WorktreePanel({ projectPath, @@ -84,6 +84,10 @@ export function WorktreePanel({ // Track whether init script exists for the project const [hasInitScript, setHasInitScript] = useState(false); + // Log panel state management + const [logPanelOpen, setLogPanelOpen] = useState(false); + const [logPanelWorktree, setLogPanelWorktree] = useState(null); + useEffect(() => { if (!projectPath) { setHasInitScript(false); @@ -164,6 +168,18 @@ export function WorktreePanel({ [projectPath] ); + // Handle opening the log panel for a specific worktree + const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => { + setLogPanelWorktree(worktree); + setLogPanelOpen(true); + }, []); + + // Handle closing the log panel + const handleCloseLogPanel = useCallback(() => { + setLogPanelOpen(false); + // Keep logPanelWorktree set for smooth close animation + }, []); + const mainWorktree = worktrees.find((w) => w.isMain); const nonMainWorktrees = worktrees.filter((w) => !w.isMain); @@ -213,6 +229,7 @@ export function WorktreePanel({ onStartDevServer={handleStartDevServer} onStopDevServer={handleStopDevServer} onOpenDevServerUrl={handleOpenDevServerUrl} + onViewDevServerLogs={handleViewDevServerLogs} onRunInitScript={handleRunInitScript} hasInitScript={hasInitScript} /> @@ -269,6 +286,7 @@ export function WorktreePanel({ onStartDevServer={handleStartDevServer} onStopDevServer={handleStopDevServer} onOpenDevServerUrl={handleOpenDevServerUrl} + onViewDevServerLogs={handleViewDevServerLogs} onRunInitScript={handleRunInitScript} hasInitScript={hasInitScript} /> @@ -303,6 +321,15 @@ export function WorktreePanel({
)} + + {/* Dev Server Logs Panel */} + ); } diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 29c8aa2e..eec4d2ca 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1760,6 +1760,22 @@ function createMockWorktreeAPI(): WorktreeAPI { }; }, + getDevServerLogs: async (worktreePath: string) => { + console.log('[Mock] Getting dev server logs:', { worktreePath }); + return { + success: false, + error: 'No dev server running for this worktree', + }; + }, + + onDevServerLogEvent: (callback) => { + console.log('[Mock] Subscribing to dev server log events'); + // Return unsubscribe function + return () => { + console.log('[Mock] Unsubscribing from dev server log events'); + }; + }, + getPRInfo: async (worktreePath: string, branchName: string) => { console.log('[Mock] Getting PR info:', { worktreePath, branchName }); return { diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index e2333520..5f9754f4 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -510,7 +510,53 @@ type EventType = | 'ideation:analysis' | 'worktree:init-started' | 'worktree:init-output' - | 'worktree:init-completed'; + | 'worktree:init-completed' + | 'dev-server:started' + | 'dev-server:output' + | 'dev-server:stopped'; + +/** + * Dev server log event payloads for WebSocket streaming + */ +export interface DevServerStartedEvent { + worktreePath: string; + port: number; + url: string; + timestamp: string; +} + +export interface DevServerOutputEvent { + worktreePath: string; + content: string; + timestamp: string; +} + +export interface DevServerStoppedEvent { + worktreePath: string; + port: number; + exitCode: number | null; + error?: string; + timestamp: string; +} + +export type DevServerLogEvent = + | { type: 'dev-server:started'; payload: DevServerStartedEvent } + | { type: 'dev-server:output'; payload: DevServerOutputEvent } + | { type: 'dev-server:stopped'; payload: DevServerStoppedEvent }; + +/** + * Response type for fetching dev server logs + */ +export interface DevServerLogsResponse { + success: boolean; + result?: { + worktreePath: string; + port: number; + logs: string; + startedAt: string; + }; + error?: string; +} type EventCallback = (payload: unknown) => void; @@ -1710,6 +1756,24 @@ export class HttpApiClient implements ElectronAPI { this.post('/api/worktree/start-dev', { projectPath, worktreePath }), stopDevServer: (worktreePath: string) => this.post('/api/worktree/stop-dev', { worktreePath }), listDevServers: () => this.post('/api/worktree/list-dev-servers', {}), + getDevServerLogs: (worktreePath: string): Promise => + this.get(`/api/worktree/dev-server-logs?worktreePath=${encodeURIComponent(worktreePath)}`), + onDevServerLogEvent: (callback: (event: DevServerLogEvent) => void) => { + const unsub1 = this.subscribeToEvent('dev-server:started', (payload) => + callback({ type: 'dev-server:started', payload: payload as DevServerStartedEvent }) + ); + const unsub2 = this.subscribeToEvent('dev-server:output', (payload) => + callback({ type: 'dev-server:output', payload: payload as DevServerOutputEvent }) + ); + const unsub3 = this.subscribeToEvent('dev-server:stopped', (payload) => + callback({ type: 'dev-server:stopped', payload: payload as DevServerStoppedEvent }) + ); + return () => { + unsub1(); + unsub2(); + unsub3(); + }; + }, getPRInfo: (worktreePath: string, branchName: string) => this.post('/api/worktree/pr-info', { worktreePath, branchName }), // Init script methods diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 4c9cce55..fd6b2968 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -978,6 +978,43 @@ export interface WorktreeAPI { error?: string; }>; + // Get buffered logs for a dev server + getDevServerLogs: (worktreePath: string) => Promise<{ + success: boolean; + result?: { + worktreePath: string; + port: number; + logs: string; + startedAt: string; + }; + error?: string; + }>; + + // Subscribe to dev server log events (started, output, stopped) + onDevServerLogEvent: ( + callback: ( + event: + | { + type: 'dev-server:started'; + payload: { worktreePath: string; port: number; url: string; timestamp: string }; + } + | { + type: 'dev-server:output'; + payload: { worktreePath: string; content: string; timestamp: string }; + } + | { + type: 'dev-server:stopped'; + payload: { + worktreePath: string; + port: number; + exitCode: number | null; + error?: string; + timestamp: string; + }; + } + ) => void + ) => () => void; + // Get PR info and comments for a branch getPRInfo: ( worktreePath: string, diff --git a/libs/types/src/event.ts b/libs/types/src/event.ts index 092c80bd..6e723d86 100644 --- a/libs/types/src/event.ts +++ b/libs/types/src/event.ts @@ -42,6 +42,9 @@ export type EventType = | 'ideation:idea-converted' | 'worktree:init-started' | 'worktree:init-output' - | 'worktree:init-completed'; + | 'worktree:init-completed' + | 'dev-server:started' + | 'dev-server:output' + | 'dev-server:stopped'; export type EventCallback = (type: EventType, payload: unknown) => void;