From 06318eef4f07d8de9ce35b50c94804991d24d4bd Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 02:33:44 -0600 Subject: [PATCH 01/74] MAESTRO: Add /web/* route namespace for web interface Created dedicated web interface route namespace in WebServer class: - /web - Root endpoint returning available interfaces info - /web/desktop - Desktop web interface entry point (placeholder) - /web/desktop/* - Wildcard for client-side routing - /web/mobile - Mobile web interface entry point (placeholder) - /web/mobile/* - Wildcard for client-side routing - /web/api - Web API namespace root with endpoint discovery This establishes the foundation for the new web interface that will provide both desktop (collaborative) and mobile (remote control) access to Maestro sessions. --- src/main/web-server.ts | 78 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/main/web-server.ts b/src/main/web-server.ts index 47af9c4c9..838475f84 100644 --- a/src/main/web-server.ts +++ b/src/main/web-server.ts @@ -102,6 +102,84 @@ export class WebServer { status: 'idle', }; }); + + // Setup web interface routes under /web/* namespace + this.setupWebInterfaceRoutes(); + } + + /** + * Setup routes for the web interface under /web/* namespace + * + * This namespace is dedicated to the new web interface that provides: + * - Desktop Web: Full-featured collaborative interface for hackathons/team coding + * - Mobile Web: Lightweight remote control for sending commands from phone + * + * Future routes planned: + * - /web/desktop - Desktop web interface entry point + * - /web/mobile - Mobile web interface entry point + * - /web/api/* - REST API endpoints for web clients + * - /ws/web - WebSocket endpoint for real-time updates to web clients + */ + private setupWebInterfaceRoutes() { + // Web interface root - returns info about available interfaces + this.server.get('/web', async () => { + return { + name: 'Maestro Web Interface', + version: '1.0.0', + interfaces: { + desktop: '/web/desktop', + mobile: '/web/mobile', + }, + api: '/web/api', + websocket: '/ws/web', + timestamp: Date.now(), + }; + }); + + // Desktop web interface entry point (placeholder) + this.server.get('/web/desktop', async () => { + return { + message: 'Desktop web interface - Coming soon', + description: 'Full-featured collaborative interface for hackathons/team coding', + }; + }); + + // Desktop web interface with wildcard for client-side routing + this.server.get('/web/desktop/*', async () => { + return { + message: 'Desktop web interface - Coming soon', + description: 'Full-featured collaborative interface for hackathons/team coding', + }; + }); + + // Mobile web interface entry point (placeholder) + this.server.get('/web/mobile', async () => { + return { + message: 'Mobile web interface - Coming soon', + description: 'Lightweight remote control for sending commands from your phone', + }; + }); + + // Mobile web interface with wildcard for client-side routing + this.server.get('/web/mobile/*', async () => { + return { + message: 'Mobile web interface - Coming soon', + description: 'Lightweight remote control for sending commands from your phone', + }; + }); + + // Web API namespace root + this.server.get('/web/api', async () => { + return { + name: 'Maestro Web API', + version: '1.0.0', + endpoints: { + sessions: '/web/api/sessions', + theme: '/web/api/theme', + }, + timestamp: Date.now(), + }; + }); } async start() { From c95380f8cbcda5df7f2247856768579e256cb12d Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 02:37:53 -0600 Subject: [PATCH 02/74] MAESTRO: Add WebSocket upgrade handler for web clients at /ws/web - Added new WebSocket endpoint at /ws/web for web interface clients - Implemented client connection tracking with unique client IDs - Added connection/disconnection event handling with logging - Added message handling for ping/pong, subscribe, and echo - Added broadcastToWebClients() method for real-time updates - Added getWebClientCount() method for monitoring connected clients - Imported WebSocket from 'ws' for readyState checks - Added WebClient and WebClientMessage type definitions --- src/main/web-server.ts | 119 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/src/main/web-server.ts b/src/main/web-server.ts index 838475f84..3eb21b04b 100644 --- a/src/main/web-server.ts +++ b/src/main/web-server.ts @@ -2,6 +2,20 @@ import Fastify from 'fastify'; import cors from '@fastify/cors'; import websocket from '@fastify/websocket'; import { FastifyInstance } from 'fastify'; +import { WebSocket } from 'ws'; + +// Types for web client messages +interface WebClientMessage { + type: string; + [key: string]: unknown; +} + +// Web client connection info +interface WebClient { + socket: WebSocket; + id: string; + connectedAt: number; +} /** * WebServer - HTTP and WebSocket server for remote access @@ -29,6 +43,8 @@ export class WebServer { private server: FastifyInstance; private port: number; private isRunning: boolean = false; + private webClients: Map = new Map(); + private clientIdCounter: number = 0; constructor(port: number = 8000) { this.port = port; @@ -180,6 +196,109 @@ export class WebServer { timestamp: Date.now(), }; }); + + // WebSocket endpoint for web interface clients + // This provides real-time updates for session state, theme changes, and log streaming + this.server.get('/ws/web', { websocket: true }, (connection) => { + const clientId = `web-client-${++this.clientIdCounter}`; + const client: WebClient = { + socket: connection.socket, + id: clientId, + connectedAt: Date.now(), + }; + + this.webClients.set(clientId, client); + console.log(`Web client connected: ${clientId} (total: ${this.webClients.size})`); + + // Send connection confirmation with client ID + connection.socket.send(JSON.stringify({ + type: 'connected', + clientId, + message: 'Connected to Maestro Web Interface', + timestamp: Date.now(), + })); + + // Handle incoming messages from web clients + connection.socket.on('message', (message) => { + try { + const data = JSON.parse(message.toString()) as WebClientMessage; + this.handleWebClientMessage(clientId, data); + } catch { + // Send error for invalid JSON + connection.socket.send(JSON.stringify({ + type: 'error', + message: 'Invalid message format', + })); + } + }); + + // Handle client disconnection + connection.socket.on('close', () => { + this.webClients.delete(clientId); + console.log(`Web client disconnected: ${clientId} (total: ${this.webClients.size})`); + }); + + // Handle errors + connection.socket.on('error', (error) => { + console.error(`Web client error (${clientId}):`, error); + this.webClients.delete(clientId); + }); + }); + } + + /** + * Handle incoming messages from web clients + */ + private handleWebClientMessage(clientId: string, message: WebClientMessage) { + const client = this.webClients.get(clientId); + if (!client) return; + + switch (message.type) { + case 'ping': + // Respond to ping with pong + client.socket.send(JSON.stringify({ + type: 'pong', + timestamp: Date.now(), + })); + break; + + case 'subscribe': + // Placeholder for subscription handling (sessions, theme, etc.) + // Will be implemented in future tasks + client.socket.send(JSON.stringify({ + type: 'subscribed', + topic: message.topic, + timestamp: Date.now(), + })); + break; + + default: + // Echo unknown message types for debugging + client.socket.send(JSON.stringify({ + type: 'echo', + originalType: message.type, + data: message, + })); + } + } + + /** + * Broadcast a message to all connected web clients + */ + broadcastToWebClients(message: object) { + const data = JSON.stringify(message); + for (const client of this.webClients.values()) { + if (client.socket.readyState === WebSocket.OPEN) { + client.socket.send(data); + } + } + } + + /** + * Get the number of connected web clients + */ + getWebClientCount(): number { + return this.webClients.size; } async start() { From f541b3f198ad0c55436046973eaa3a5a430900bd Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 02:41:29 -0600 Subject: [PATCH 03/74] MAESTRO: Add optional PIN/token authentication for web interface - Add WebAuthConfig type and auth state management in WebServer - Generate 6-character alphanumeric PINs (excludes confusing chars) - Support authentication via: - WebSocket query string (?token=XXX) - WebSocket auth message { type: 'auth', token: '' } - REST API headers (Authorization: Bearer or X-Auth-Token) - Add IPC handlers for auth management: - webserver:getAuthConfig - webserver:setAuthEnabled (auto-generates token if needed) - webserver:generateNewToken - webserver:setAuthToken - webserver:getConnectedClients - Persist auth settings in electron-store - Add /web/api/auth/status and /web/api/auth/verify endpoints --- src/main/index.ts | 68 +++++++++++++++ src/main/preload.ts | 11 +++ src/main/web-server.ts | 182 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 251 insertions(+), 10 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index d0e1b4069..df209cac5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -25,6 +25,9 @@ interface MaestroSettings { customFonts: string[]; logLevel: 'debug' | 'info' | 'warn' | 'error'; defaultShell: string; + // Web interface authentication + webAuthEnabled: boolean; + webAuthToken: string | null; } const store = new Store({ @@ -43,6 +46,8 @@ const store = new Store({ customFonts: [], logLevel: 'info', defaultShell: 'zsh', + webAuthEnabled: false, + webAuthToken: null, }, }); @@ -279,6 +284,15 @@ app.whenReady().then(() => { webServer = new WebServer(8000); agentDetector = new AgentDetector(); + // Initialize web server auth from stored settings + const webAuthEnabled = store.get('webAuthEnabled', false); + const webAuthToken = store.get('webAuthToken', null); + webServer.setAuthConfig({ + enabled: webAuthEnabled, + token: webAuthToken, + }); + logger.info(`Web server auth initialized: enabled=${webAuthEnabled}`, 'WebServer'); + // Initialize session web server manager with callbacks sessionWebServerManager = new SessionWebServerManager( // getSessionData callback - fetch session from store @@ -682,6 +696,60 @@ function setupIpcHandlers() { return webServer?.getUrl(); }); + // Web server authentication management + ipcMain.handle('webserver:getAuthConfig', async () => { + return webServer?.getAuthConfig() || { enabled: false, token: null }; + }); + + ipcMain.handle('webserver:setAuthEnabled', async (_, enabled: boolean) => { + if (!webServer) throw new Error('Web server not initialized'); + + const currentConfig = webServer.getAuthConfig(); + let token = currentConfig.token; + + // Generate a new token if enabling auth and no token exists + if (enabled && !token) { + const { WebServer } = await import('./web-server'); + token = WebServer.generateToken(); + store.set('webAuthToken', token); + } + + webServer.setAuthConfig({ enabled, token }); + store.set('webAuthEnabled', enabled); + + logger.info(`Web server auth ${enabled ? 'enabled' : 'disabled'}`, 'WebServer'); + return { enabled, token }; + }); + + ipcMain.handle('webserver:generateNewToken', async () => { + if (!webServer) throw new Error('Web server not initialized'); + + const { WebServer } = await import('./web-server'); + const newToken = WebServer.generateToken(); + const currentConfig = webServer.getAuthConfig(); + + webServer.setAuthConfig({ ...currentConfig, token: newToken }); + store.set('webAuthToken', newToken); + + logger.info('Generated new web auth token', 'WebServer'); + return newToken; + }); + + ipcMain.handle('webserver:setAuthToken', async (_, token: string | null) => { + if (!webServer) throw new Error('Web server not initialized'); + + const currentConfig = webServer.getAuthConfig(); + webServer.setAuthConfig({ ...currentConfig, token }); + store.set('webAuthToken', token); + + logger.info('Updated web auth token', 'WebServer'); + return true; + }); + + ipcMain.handle('webserver:getConnectedClients', async () => { + return webServer?.getWebClientCount() || 0; + }); + // Helper to strip non-serializable functions from agent configs const stripAgentFunctions = (agent: any) => { if (!agent) return null; diff --git a/src/main/preload.ts b/src/main/preload.ts index 79dbadecb..4673ef310 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -133,6 +133,12 @@ contextBridge.exposeInMainWorld('maestro', { // Web Server API webserver: { getUrl: () => ipcRenderer.invoke('webserver:getUrl'), + // Authentication management + getAuthConfig: () => ipcRenderer.invoke('webserver:getAuthConfig'), + setAuthEnabled: (enabled: boolean) => ipcRenderer.invoke('webserver:setAuthEnabled', enabled), + generateNewToken: () => ipcRenderer.invoke('webserver:generateNewToken'), + setAuthToken: (token: string | null) => ipcRenderer.invoke('webserver:setAuthToken', token), + getConnectedClients: () => ipcRenderer.invoke('webserver:getConnectedClients'), }, // Tunnel API - per-session local web server @@ -318,6 +324,11 @@ export interface MaestroAPI { }; webserver: { getUrl: () => Promise; + getAuthConfig: () => Promise<{ enabled: boolean; token: string | null }>; + setAuthEnabled: (enabled: boolean) => Promise<{ enabled: boolean; token: string | null }>; + generateNewToken: () => Promise; + setAuthToken: (token: string | null) => Promise; + getConnectedClients: () => Promise; }; tunnel: { start: (sessionId: string) => Promise<{ port: number; uuid: string; url: string }>; diff --git a/src/main/web-server.ts b/src/main/web-server.ts index 3eb21b04b..6cc819108 100644 --- a/src/main/web-server.ts +++ b/src/main/web-server.ts @@ -1,8 +1,9 @@ import Fastify from 'fastify'; import cors from '@fastify/cors'; import websocket from '@fastify/websocket'; -import { FastifyInstance } from 'fastify'; +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { WebSocket } from 'ws'; +import crypto from 'crypto'; // Types for web client messages interface WebClientMessage { @@ -15,6 +16,13 @@ interface WebClient { socket: WebSocket; id: string; connectedAt: number; + authenticated: boolean; +} + +// Authentication configuration +export interface WebAuthConfig { + enabled: boolean; + token: string | null; } /** @@ -45,6 +53,7 @@ export class WebServer { private isRunning: boolean = false; private webClients: Map = new Map(); private clientIdCounter: number = 0; + private authConfig: WebAuthConfig = { enabled: false, token: null }; constructor(port: number = 8000) { this.port = port; @@ -58,6 +67,77 @@ export class WebServer { this.setupRoutes(); } + /** + * Set the authentication configuration + */ + setAuthConfig(config: WebAuthConfig) { + this.authConfig = config; + console.log(`Web server auth ${config.enabled ? 'enabled' : 'disabled'}`); + } + + /** + * Get the current authentication configuration + */ + getAuthConfig(): WebAuthConfig { + return { ...this.authConfig }; + } + + /** + * Generate a new random authentication token + */ + static generateToken(): string { + // Generate a 6-character alphanumeric PIN (easy to type on mobile) + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Exclude confusing chars like 0/O, 1/I/L + let token = ''; + const bytes = crypto.randomBytes(6); + for (let i = 0; i < 6; i++) { + token += chars[bytes[i] % chars.length]; + } + return token; + } + + /** + * Validate an authentication token + */ + validateToken(token: string): boolean { + if (!this.authConfig.enabled || !this.authConfig.token) { + return true; // Auth disabled, all tokens valid + } + return token.toUpperCase() === this.authConfig.token.toUpperCase(); + } + + /** + * Authentication hook for protected REST API routes + * Used as a preHandler for routes that require authentication + * + * Usage: this.server.get('/protected', { preHandler: this.authenticateRequest.bind(this) }, handler) + */ + authenticateRequest = async (request: FastifyRequest, reply: FastifyReply) => { + if (!this.authConfig.enabled || !this.authConfig.token) { + return; // Auth disabled, allow all + } + + // Check for token in Authorization header (Bearer token) or X-Auth-Token header + const authHeader = request.headers.authorization; + const xAuthToken = request.headers['x-auth-token'] as string | undefined; + + let token: string | null = null; + + if (authHeader?.startsWith('Bearer ')) { + token = authHeader.slice(7); + } else if (xAuthToken) { + token = xAuthToken; + } + + if (!token || !this.validateToken(token)) { + reply.code(401).send({ + error: 'Unauthorized', + message: 'Valid authentication token required. Provide token via Authorization header (Bearer ) or X-Auth-Token header.' + }); + return reply; + } + }; + private async setupMiddleware() { // Enable CORS for web access await this.server.register(cors, { @@ -199,29 +279,84 @@ export class WebServer { // WebSocket endpoint for web interface clients // This provides real-time updates for session state, theme changes, and log streaming - this.server.get('/ws/web', { websocket: true }, (connection) => { + // Authentication: If auth is enabled, client must send { type: 'auth', token: '' } first + this.server.get('/ws/web', { websocket: true }, (connection, request) => { const clientId = `web-client-${++this.clientIdCounter}`; + + // Check if auth is required + const authRequired = this.authConfig.enabled && this.authConfig.token; + + // Check for token in query string (allows direct connection with ?token=XXX) + const url = new URL(request.url || '', `http://${request.headers.host || 'localhost'}`); + const queryToken = url.searchParams.get('token'); + const initiallyAuthenticated = queryToken ? this.validateToken(queryToken) : !authRequired; + const client: WebClient = { socket: connection.socket, id: clientId, connectedAt: Date.now(), + authenticated: initiallyAuthenticated, }; this.webClients.set(clientId, client); - console.log(`Web client connected: ${clientId} (total: ${this.webClients.size})`); + console.log(`Web client connected: ${clientId} (authenticated: ${client.authenticated}, total: ${this.webClients.size})`); - // Send connection confirmation with client ID - connection.socket.send(JSON.stringify({ - type: 'connected', - clientId, - message: 'Connected to Maestro Web Interface', - timestamp: Date.now(), - })); + if (client.authenticated) { + // Send connection confirmation with client ID + connection.socket.send(JSON.stringify({ + type: 'connected', + clientId, + message: 'Connected to Maestro Web Interface', + authenticated: true, + timestamp: Date.now(), + })); + } else { + // Send auth required message + connection.socket.send(JSON.stringify({ + type: 'auth_required', + clientId, + message: 'Authentication required. Send { type: "auth", token: "" } to authenticate.', + timestamp: Date.now(), + })); + } // Handle incoming messages from web clients connection.socket.on('message', (message) => { try { const data = JSON.parse(message.toString()) as WebClientMessage; + + // Handle authentication message + if (data.type === 'auth') { + const token = data.token as string; + if (this.validateToken(token || '')) { + client.authenticated = true; + connection.socket.send(JSON.stringify({ + type: 'auth_success', + clientId, + message: 'Authentication successful', + timestamp: Date.now(), + })); + console.log(`Web client authenticated: ${clientId}`); + } else { + connection.socket.send(JSON.stringify({ + type: 'auth_failed', + message: 'Invalid authentication token', + timestamp: Date.now(), + })); + console.log(`Web client auth failed: ${clientId}`); + } + return; + } + + // Reject messages from unauthenticated clients (except auth messages) + if (!client.authenticated) { + connection.socket.send(JSON.stringify({ + type: 'error', + message: 'Not authenticated. Send { type: "auth", token: "" } first.', + })); + return; + } + this.handleWebClientMessage(clientId, data); } catch { // Send error for invalid JSON @@ -244,6 +379,33 @@ export class WebServer { this.webClients.delete(clientId); }); }); + + // Authentication status endpoint - allows checking if auth is enabled + this.server.get('/web/api/auth/status', async () => { + return { + enabled: this.authConfig.enabled, + timestamp: Date.now(), + }; + }); + + // Authentication verification endpoint - checks if a token is valid + this.server.post('/web/api/auth/verify', async (request) => { + const body = request.body as { token?: string } | undefined; + const token = body?.token; + + if (!token) { + return { + valid: false, + message: 'No token provided', + }; + } + + const valid = this.validateToken(token); + return { + valid, + message: valid ? 'Token is valid' : 'Invalid token', + }; + }); } /** From d40d36a43bfcd3b9ace1ba9a4a826a4a07637939 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 02:46:14 -0600 Subject: [PATCH 04/74] MAESTRO: Broadcast session state changes to connected web clients Added real-time session state broadcasting to the web interface: - Added broadcastSessionStateChange() method to broadcast when session state, name, or input mode changes - Added broadcastSessionAdded() and broadcastSessionRemoved() methods for tracking session lifecycle - Added broadcastSessionsList() method for bulk session sync - Modified sessions:setAll IPC handler to detect session changes and broadcast them to all authenticated web clients - Added setGetSessionsCallback() to allow web server to fetch current sessions - Send initial sessions_list to newly connected/authenticated web clients - Only broadcast to authenticated clients for security WebSocket message types added: - session_state_change: { type, sessionId, state, name?, toolType?, inputMode?, cwd?, timestamp } - session_added: { type, session, timestamp } - session_removed: { type, sessionId, timestamp } - sessions_list: { type, sessions, timestamp } --- src/main/index.ts | 56 +++++++++++++++++++++ src/main/web-server.ts | 108 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/src/main/index.ts b/src/main/index.ts index df209cac5..0b7d3f34e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -293,6 +293,19 @@ app.whenReady().then(() => { }); logger.info(`Web server auth initialized: enabled=${webAuthEnabled}`, 'WebServer'); + // Set up callback for web server to fetch sessions list + webServer.setGetSessionsCallback(() => { + const sessions = sessionsStore.get('sessions', []); + return sessions.map((s: any) => ({ + id: s.id, + name: s.name, + toolType: s.toolType, + state: s.state, + inputMode: s.inputMode, + cwd: s.cwd, + })); + }); + // Initialize session web server manager with callbacks sessionWebServerManager = new SessionWebServerManager( // getSessionData callback - fetch session from store @@ -386,6 +399,49 @@ function setupIpcHandlers() { }); ipcMain.handle('sessions:setAll', async (_, sessions: any[]) => { + // Get previous sessions to detect changes + const previousSessions = sessionsStore.get('sessions', []); + const previousSessionMap = new Map(previousSessions.map((s: any) => [s.id, s])); + const currentSessionMap = new Map(sessions.map((s: any) => [s.id, s])); + + // Detect and broadcast changes to web clients + if (webServer && webServer.getWebClientCount() > 0) { + // Check for state changes in existing sessions + for (const session of sessions) { + const prevSession = previousSessionMap.get(session.id); + if (prevSession) { + // Session exists - check if state changed + if (prevSession.state !== session.state || + prevSession.inputMode !== session.inputMode || + prevSession.name !== session.name) { + webServer.broadcastSessionStateChange(session.id, session.state, { + name: session.name, + toolType: session.toolType, + inputMode: session.inputMode, + cwd: session.cwd, + }); + } + } else { + // New session added + webServer.broadcastSessionAdded({ + id: session.id, + name: session.name, + toolType: session.toolType, + state: session.state, + inputMode: session.inputMode, + cwd: session.cwd, + }); + } + } + + // Check for removed sessions + for (const prevSession of previousSessions) { + if (!currentSessionMap.has(prevSession.id)) { + webServer.broadcastSessionRemoved(prevSession.id); + } + } + } + sessionsStore.set('sessions', sessions); return true; }); diff --git a/src/main/web-server.ts b/src/main/web-server.ts index 6cc819108..140755d5a 100644 --- a/src/main/web-server.ts +++ b/src/main/web-server.ts @@ -47,6 +47,16 @@ export interface WebAuthConfig { * * See PRD.md Phase 6 for full requirements. */ +// Callback type for fetching sessions data +export type GetSessionsCallback = () => Array<{ + id: string; + name: string; + toolType: string; + state: string; + inputMode: string; + cwd: string; +}>; + export class WebServer { private server: FastifyInstance; private port: number; @@ -54,6 +64,7 @@ export class WebServer { private webClients: Map = new Map(); private clientIdCounter: number = 0; private authConfig: WebAuthConfig = { enabled: false, token: null }; + private getSessionsCallback: GetSessionsCallback | null = null; constructor(port: number = 8000) { this.port = port; @@ -67,6 +78,14 @@ export class WebServer { this.setupRoutes(); } + /** + * Set the callback function for fetching current sessions list + * This is called when a new client connects to send the initial state + */ + setGetSessionsCallback(callback: GetSessionsCallback) { + this.getSessionsCallback = callback; + } + /** * Set the authentication configuration */ @@ -310,6 +329,16 @@ export class WebServer { authenticated: true, timestamp: Date.now(), })); + + // Send initial sessions list to newly connected client + if (this.getSessionsCallback) { + const sessions = this.getSessionsCallback(); + connection.socket.send(JSON.stringify({ + type: 'sessions_list', + sessions, + timestamp: Date.now(), + })); + } } else { // Send auth required message connection.socket.send(JSON.stringify({ @@ -337,6 +366,16 @@ export class WebServer { timestamp: Date.now(), })); console.log(`Web client authenticated: ${clientId}`); + + // Send initial sessions list to newly authenticated client + if (this.getSessionsCallback) { + const sessions = this.getSessionsCallback(); + connection.socket.send(JSON.stringify({ + type: 'sessions_list', + sessions, + timestamp: Date.now(), + })); + } } else { connection.socket.send(JSON.stringify({ type: 'auth_failed', @@ -450,12 +489,79 @@ export class WebServer { broadcastToWebClients(message: object) { const data = JSON.stringify(message); for (const client of this.webClients.values()) { - if (client.socket.readyState === WebSocket.OPEN) { + if (client.socket.readyState === WebSocket.OPEN && client.authenticated) { client.socket.send(data); } } } + /** + * Broadcast a session state change to all connected web clients + * Called when any session's state changes (idle, busy, error, connecting) + */ + broadcastSessionStateChange(sessionId: string, state: string, additionalData?: { + name?: string; + toolType?: string; + inputMode?: string; + cwd?: string; + }) { + this.broadcastToWebClients({ + type: 'session_state_change', + sessionId, + state, + ...additionalData, + timestamp: Date.now(), + }); + } + + /** + * Broadcast when a session is added + */ + broadcastSessionAdded(session: { + id: string; + name: string; + toolType: string; + state: string; + inputMode: string; + cwd: string; + }) { + this.broadcastToWebClients({ + type: 'session_added', + session, + timestamp: Date.now(), + }); + } + + /** + * Broadcast when a session is removed + */ + broadcastSessionRemoved(sessionId: string) { + this.broadcastToWebClients({ + type: 'session_removed', + sessionId, + timestamp: Date.now(), + }); + } + + /** + * Broadcast the full sessions list to all connected web clients + * Used for initial sync or bulk updates + */ + broadcastSessionsList(sessions: Array<{ + id: string; + name: string; + toolType: string; + state: string; + inputMode: string; + cwd: string; + }>) { + this.broadcastToWebClients({ + type: 'sessions_list', + sessions, + timestamp: Date.now(), + }); + } + /** * Get the number of connected web clients */ From 14ff05d36fefd1ab21751c227366837744a5e3ab Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 02:50:29 -0600 Subject: [PATCH 05/74] MAESTRO: Send current theme on initial WebSocket connection - Add WebTheme type and GetThemeCallback to web-server.ts - Add setGetThemeCallback method to WebServer class - Send theme after sessions list on initial connection - Send theme after auth success for authenticated clients - Create src/main/themes.ts with all theme definitions for main process - Wire up theme callback in index.ts using getThemeById helper --- src/main/index.ts | 7 + src/main/themes.ts | 325 +++++++++++++++++++++++++++++++++++++++++ src/main/web-server.ts | 57 ++++++++ 3 files changed, 389 insertions(+) create mode 100644 src/main/themes.ts diff --git a/src/main/index.ts b/src/main/index.ts index 0b7d3f34e..46819d758 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -8,6 +8,7 @@ import { AgentDetector } from './agent-detector'; import { execFileNoThrow } from './utils/execFile'; import { logger } from './utils/logger'; import { detectShells } from './utils/shellDetector'; +import { getThemeById } from './themes'; import Store from 'electron-store'; // Type definitions @@ -306,6 +307,12 @@ app.whenReady().then(() => { })); }); + // Set up callback for web server to fetch current theme + webServer.setGetThemeCallback(() => { + const themeId = store.get('activeThemeId', 'dracula'); + return getThemeById(themeId); + }); + // Initialize session web server manager with callbacks sessionWebServerManager = new SessionWebServerManager( // getSessionData callback - fetch session from store diff --git a/src/main/themes.ts b/src/main/themes.ts new file mode 100644 index 000000000..c8f484737 --- /dev/null +++ b/src/main/themes.ts @@ -0,0 +1,325 @@ +// Theme definitions for the web interface +// This mirrors src/renderer/constants/themes.ts for use in the main process +// When themes are updated in the renderer, this file should also be updated + +import type { WebTheme } from './web-server'; + +export type ThemeId = 'dracula' | 'monokai' | 'github-light' | 'solarized-light' | 'nord' | 'tokyo-night' | 'one-light' | 'gruvbox-light' | 'catppuccin-mocha' | 'gruvbox-dark' | 'catppuccin-latte' | 'ayu-light' | 'pedurple' | 'maestros-choice' | 'dre-synth' | 'inquest'; + +export const THEMES: Record = { + // Dark themes + dracula: { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#0b0b0d', + bgSidebar: '#111113', + bgActivity: '#1c1c1f', + border: '#27272a', + textMain: '#e4e4e7', + textDim: '#a1a1aa', + accent: '#6366f1', + accentDim: 'rgba(99, 102, 241, 0.2)', + accentText: '#a5b4fc', + success: '#22c55e', + warning: '#eab308', + error: '#ef4444' + } + }, + monokai: { + id: 'monokai', + name: 'Monokai', + mode: 'dark', + colors: { + bgMain: '#272822', + bgSidebar: '#1e1f1c', + bgActivity: '#3e3d32', + border: '#49483e', + textMain: '#f8f8f2', + textDim: '#8f908a', + accent: '#fd971f', + accentDim: 'rgba(253, 151, 31, 0.2)', + accentText: '#fdbf6f', + success: '#a6e22e', + warning: '#e6db74', + error: '#f92672' + } + }, + nord: { + id: 'nord', + name: 'Nord', + mode: 'dark', + colors: { + bgMain: '#2e3440', + bgSidebar: '#3b4252', + bgActivity: '#434c5e', + border: '#4c566a', + textMain: '#eceff4', + textDim: '#d8dee9', + accent: '#88c0d0', + accentDim: 'rgba(136, 192, 208, 0.2)', + accentText: '#8fbcbb', + success: '#a3be8c', + warning: '#ebcb8b', + error: '#bf616a' + } + }, + 'tokyo-night': { + id: 'tokyo-night', + name: 'Tokyo Night', + mode: 'dark', + colors: { + bgMain: '#1a1b26', + bgSidebar: '#16161e', + bgActivity: '#24283b', + border: '#414868', + textMain: '#c0caf5', + textDim: '#9aa5ce', + accent: '#7aa2f7', + accentDim: 'rgba(122, 162, 247, 0.2)', + accentText: '#7dcfff', + success: '#9ece6a', + warning: '#e0af68', + error: '#f7768e' + } + }, + 'catppuccin-mocha': { + id: 'catppuccin-mocha', + name: 'Catppuccin Mocha', + mode: 'dark', + colors: { + bgMain: '#1e1e2e', + bgSidebar: '#181825', + bgActivity: '#313244', + border: '#45475a', + textMain: '#cdd6f4', + textDim: '#bac2de', + accent: '#89b4fa', + accentDim: 'rgba(137, 180, 250, 0.2)', + accentText: '#89dceb', + success: '#a6e3a1', + warning: '#f9e2af', + error: '#f38ba8' + } + }, + 'gruvbox-dark': { + id: 'gruvbox-dark', + name: 'Gruvbox Dark', + mode: 'dark', + colors: { + bgMain: '#282828', + bgSidebar: '#1d2021', + bgActivity: '#3c3836', + border: '#504945', + textMain: '#ebdbb2', + textDim: '#a89984', + accent: '#83a598', + accentDim: 'rgba(131, 165, 152, 0.2)', + accentText: '#8ec07c', + success: '#b8bb26', + warning: '#fabd2f', + error: '#fb4934' + } + }, + // Light themes + 'github-light': { + id: 'github-light', + name: 'GitHub', + mode: 'light', + colors: { + bgMain: '#ffffff', + bgSidebar: '#f6f8fa', + bgActivity: '#eff2f5', + border: '#d0d7de', + textMain: '#24292f', + textDim: '#57606a', + accent: '#0969da', + accentDim: 'rgba(9, 105, 218, 0.1)', + accentText: '#0969da', + success: '#1a7f37', + warning: '#9a6700', + error: '#cf222e' + } + }, + 'solarized-light': { + id: 'solarized-light', + name: 'Solarized', + mode: 'light', + colors: { + bgMain: '#fdf6e3', + bgSidebar: '#eee8d5', + bgActivity: '#e6dfc8', + border: '#d3cbb7', + textMain: '#657b83', + textDim: '#93a1a1', + accent: '#2aa198', + accentDim: 'rgba(42, 161, 152, 0.1)', + accentText: '#2aa198', + success: '#859900', + warning: '#b58900', + error: '#dc322f' + } + }, + 'one-light': { + id: 'one-light', + name: 'One Light', + mode: 'light', + colors: { + bgMain: '#fafafa', + bgSidebar: '#f0f0f0', + bgActivity: '#e5e5e6', + border: '#d0d0d0', + textMain: '#383a42', + textDim: '#a0a1a7', + accent: '#4078f2', + accentDim: 'rgba(64, 120, 242, 0.1)', + accentText: '#4078f2', + success: '#50a14f', + warning: '#c18401', + error: '#e45649' + } + }, + 'gruvbox-light': { + id: 'gruvbox-light', + name: 'Gruvbox Light', + mode: 'light', + colors: { + bgMain: '#fbf1c7', + bgSidebar: '#ebdbb2', + bgActivity: '#d5c4a1', + border: '#bdae93', + textMain: '#3c3836', + textDim: '#7c6f64', + accent: '#458588', + accentDim: 'rgba(69, 133, 136, 0.1)', + accentText: '#076678', + success: '#98971a', + warning: '#d79921', + error: '#cc241d' + } + }, + 'catppuccin-latte': { + id: 'catppuccin-latte', + name: 'Catppuccin Latte', + mode: 'light', + colors: { + bgMain: '#eff1f5', + bgSidebar: '#e6e9ef', + bgActivity: '#dce0e8', + border: '#bcc0cc', + textMain: '#4c4f69', + textDim: '#5c5f77', + accent: '#1e66f5', + accentDim: 'rgba(30, 102, 245, 0.1)', + accentText: '#1e66f5', + success: '#40a02b', + warning: '#df8e1d', + error: '#d20f39' + } + }, + 'ayu-light': { + id: 'ayu-light', + name: 'Ayu Light', + mode: 'light', + colors: { + bgMain: '#fafafa', + bgSidebar: '#f3f4f5', + bgActivity: '#e7e8e9', + border: '#d9d9d9', + textMain: '#5c6166', + textDim: '#828c99', + accent: '#55b4d4', + accentDim: 'rgba(85, 180, 212, 0.1)', + accentText: '#399ee6', + success: '#86b300', + warning: '#f2ae49', + error: '#f07171' + } + }, + // Vibe themes + pedurple: { + id: 'pedurple', + name: 'Pedurple', + mode: 'vibe', + colors: { + bgMain: '#1a0f24', + bgSidebar: '#140a1c', + bgActivity: '#2a1a3a', + border: '#4a2a6a', + textMain: '#e8d5f5', + textDim: '#b89fd0', + accent: '#d4af37', + accentDim: 'rgba(212, 175, 55, 0.25)', + accentText: '#ffd700', + success: '#7cb342', + warning: '#ff69b4', + error: '#da70d6' + } + }, + 'maestros-choice': { + id: 'maestros-choice', + name: "Maestro's Choice", + mode: 'vibe', + colors: { + bgMain: '#0a0a0f', + bgSidebar: '#05050a', + bgActivity: '#12121a', + border: '#2a2a3a', + textMain: '#f0e6d3', + textDim: '#8a8078', + accent: '#c9a227', + accentDim: 'rgba(201, 162, 39, 0.2)', + accentText: '#e6b830', + success: '#4a9c6d', + warning: '#c9a227', + error: '#8b2942' + } + }, + 'dre-synth': { + id: 'dre-synth', + name: 'Dre Synth', + mode: 'vibe', + colors: { + bgMain: '#0d0221', + bgSidebar: '#0a0118', + bgActivity: '#150530', + border: '#2a1050', + textMain: '#f0e6ff', + textDim: '#9080b0', + accent: '#ff2a6d', + accentDim: 'rgba(255, 42, 109, 0.25)', + accentText: '#ff6b9d', + success: '#05ffa1', + warning: '#00f5d4', + error: '#ff2a6d' + } + }, + inquest: { + id: 'inquest', + name: 'InQuest', + mode: 'vibe', + colors: { + bgMain: '#0a0a0a', + bgSidebar: '#050505', + bgActivity: '#141414', + border: '#2a2a2a', + textMain: '#f5f5f5', + textDim: '#888888', + accent: '#cc0033', + accentDim: 'rgba(204, 0, 51, 0.25)', + accentText: '#ff3355', + success: '#f5f5f5', + warning: '#cc0033', + error: '#cc0033' + } + } +}; + +/** + * Get a theme by its ID + * Returns null if the theme ID is not found + */ +export function getThemeById(themeId: string): WebTheme | null { + return THEMES[themeId as ThemeId] || null; +} diff --git a/src/main/web-server.ts b/src/main/web-server.ts index 140755d5a..1cf1b128f 100644 --- a/src/main/web-server.ts +++ b/src/main/web-server.ts @@ -57,6 +57,30 @@ export type GetSessionsCallback = () => Array<{ cwd: string; }>; +// Theme type for web clients (matches renderer/types/index.ts) +export interface WebTheme { + id: string; + name: string; + mode: 'light' | 'dark' | 'vibe'; + colors: { + bgMain: string; + bgSidebar: string; + bgActivity: string; + border: string; + textMain: string; + textDim: string; + accent: string; + accentDim: string; + accentText: string; + success: string; + warning: string; + error: string; + }; +} + +// Callback type for fetching current theme +export type GetThemeCallback = () => WebTheme | null; + export class WebServer { private server: FastifyInstance; private port: number; @@ -65,6 +89,7 @@ export class WebServer { private clientIdCounter: number = 0; private authConfig: WebAuthConfig = { enabled: false, token: null }; private getSessionsCallback: GetSessionsCallback | null = null; + private getThemeCallback: GetThemeCallback | null = null; constructor(port: number = 8000) { this.port = port; @@ -86,6 +111,14 @@ export class WebServer { this.getSessionsCallback = callback; } + /** + * Set the callback function for fetching current theme + * This is called when a new client connects to send the initial theme + */ + setGetThemeCallback(callback: GetThemeCallback) { + this.getThemeCallback = callback; + } + /** * Set the authentication configuration */ @@ -339,6 +372,18 @@ export class WebServer { timestamp: Date.now(), })); } + + // Send current theme to newly connected client + if (this.getThemeCallback) { + const theme = this.getThemeCallback(); + if (theme) { + connection.socket.send(JSON.stringify({ + type: 'theme', + theme, + timestamp: Date.now(), + })); + } + } } else { // Send auth required message connection.socket.send(JSON.stringify({ @@ -376,6 +421,18 @@ export class WebServer { timestamp: Date.now(), })); } + + // Send current theme to newly authenticated client + if (this.getThemeCallback) { + const theme = this.getThemeCallback(); + if (theme) { + connection.socket.send(JSON.stringify({ + type: 'theme', + theme, + timestamp: Date.now(), + })); + } + } } else { connection.socket.send(JSON.stringify({ type: 'auth_failed', From 55761ee5db6516e50b62fe86ff42bd5c0393891c Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 02:52:22 -0600 Subject: [PATCH 06/74] MAESTRO: Broadcast theme changes to connected web clients Added broadcastThemeChange method to WebServer class and integrated it with the settings:set IPC handler to automatically notify all connected web clients when the user switches themes in the desktop app. --- src/main/index.ts | 10 ++++++++++ src/main/web-server.ts | 12 ++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/main/index.ts b/src/main/index.ts index 46819d758..8bf50d437 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -391,6 +391,16 @@ function setupIpcHandlers() { ipcMain.handle('settings:set', async (_, key: string, value: any) => { store.set(key, value); logger.info(`Settings updated: ${key}`, 'Settings', { key, value }); + + // Broadcast theme changes to connected web clients + if (key === 'activeThemeId' && webServer && webServer.getWebClientCount() > 0) { + const theme = getThemeById(value); + if (theme) { + webServer.broadcastThemeChange(theme); + logger.info(`Broadcasted theme change to web clients: ${value}`, 'WebServer'); + } + } + return true; }); diff --git a/src/main/web-server.ts b/src/main/web-server.ts index 1cf1b128f..02918e7b0 100644 --- a/src/main/web-server.ts +++ b/src/main/web-server.ts @@ -619,6 +619,18 @@ export class WebServer { }); } + /** + * Broadcast theme change to all connected web clients + * Called when the user changes the theme in the desktop app + */ + broadcastThemeChange(theme: WebTheme) { + this.broadcastToWebClients({ + type: 'theme', + theme, + timestamp: Date.now(), + }); + } + /** * Get the number of connected web clients */ From 138ba5aeb4ece2f12a6a31a10e9556e29c6dc462 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 02:55:51 -0600 Subject: [PATCH 07/74] MAESTRO: Add rate limiting for web interface endpoints - Install @fastify/rate-limit package - Configure rate limiting with sensible defaults: - 100 requests/minute for GET endpoints - 30 requests/minute for POST endpoints (more restrictive) - Add RateLimitConfig interface for configuration - Apply rate limiting to all /web/* routes - Add /web/api/rate-limit endpoint to check current limits - Skip rate limiting for /health endpoint - Custom error response with retry-after information - Support for X-Forwarded-For header for proxied requests --- package-lock.json | 47 ++++++++++++++ package.json | 1 + src/main/web-server.ts | 135 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 175 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8fc700565..e797bac58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@fastify/cors": "^8.5.0", + "@fastify/rate-limit": "^10.3.0", "@fastify/websocket": "^9.0.0", "@types/dompurify": "^3.0.5", "ansi-to-html": "^0.7.2", @@ -1203,6 +1204,43 @@ "fast-deep-equal": "^3.1.3" } }, + "node_modules/@fastify/rate-limit": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", + "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/rate-limit/node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/websocket": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-9.0.0.tgz", @@ -1396,6 +1434,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@malept/cross-spawn-promise": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", diff --git a/package.json b/package.json index 0a5e9e829..aa8c59a7d 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@fastify/cors": "^8.5.0", + "@fastify/rate-limit": "^10.3.0", "@fastify/websocket": "^9.0.0", "@types/dompurify": "^3.0.5", "ansi-to-html": "^0.7.2", diff --git a/src/main/web-server.ts b/src/main/web-server.ts index 02918e7b0..993724d5f 100644 --- a/src/main/web-server.ts +++ b/src/main/web-server.ts @@ -1,6 +1,7 @@ import Fastify from 'fastify'; import cors from '@fastify/cors'; import websocket from '@fastify/websocket'; +import rateLimit from '@fastify/rate-limit'; import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { WebSocket } from 'ws'; import crypto from 'crypto'; @@ -25,6 +26,18 @@ export interface WebAuthConfig { token: string | null; } +// Rate limiting configuration +export interface RateLimitConfig { + // Maximum requests per time window + max: number; + // Time window in milliseconds + timeWindow: number; + // Maximum requests for POST endpoints (typically lower) + maxPost: number; + // Enable/disable rate limiting + enabled: boolean; +} + /** * WebServer - HTTP and WebSocket server for remote access * @@ -81,6 +94,14 @@ export interface WebTheme { // Callback type for fetching current theme export type GetThemeCallback = () => WebTheme | null; +// Default rate limit configuration +const DEFAULT_RATE_LIMIT_CONFIG: RateLimitConfig = { + max: 100, // 100 requests per minute for GET endpoints + timeWindow: 60000, // 1 minute in milliseconds + maxPost: 30, // 30 requests per minute for POST endpoints (more restrictive) + enabled: true, +}; + export class WebServer { private server: FastifyInstance; private port: number; @@ -88,6 +109,7 @@ export class WebServer { private webClients: Map = new Map(); private clientIdCounter: number = 0; private authConfig: WebAuthConfig = { enabled: false, token: null }; + private rateLimitConfig: RateLimitConfig = { ...DEFAULT_RATE_LIMIT_CONFIG }; private getSessionsCallback: GetSessionsCallback | null = null; private getThemeCallback: GetThemeCallback | null = null; @@ -134,6 +156,21 @@ export class WebServer { return { ...this.authConfig }; } + /** + * Set the rate limiting configuration + */ + setRateLimitConfig(config: Partial) { + this.rateLimitConfig = { ...this.rateLimitConfig, ...config }; + console.log(`Web server rate limiting ${this.rateLimitConfig.enabled ? 'enabled' : 'disabled'} (max: ${this.rateLimitConfig.max}/min, maxPost: ${this.rateLimitConfig.maxPost}/min)`); + } + + /** + * Get the current rate limiting configuration + */ + getRateLimitConfig(): RateLimitConfig { + return { ...this.rateLimitConfig }; + } + /** * Generate a new random authentication token */ @@ -198,6 +235,44 @@ export class WebServer { // Enable WebSocket support await this.server.register(websocket); + + // Enable rate limiting for web interface endpoints to prevent abuse + // Rate limiting is applied globally but can be overridden per-route + await this.server.register(rateLimit, { + global: false, // Don't apply to all routes by default (we'll apply selectively) + max: this.rateLimitConfig.max, + timeWindow: this.rateLimitConfig.timeWindow, + // Custom error response + errorResponseBuilder: ( + _request: FastifyRequest, + context: { statusCode: number; ban: boolean; after: string; max: number; ttl: number } + ) => { + return { + statusCode: 429, + error: 'Too Many Requests', + message: `Rate limit exceeded. You can make ${context.max} requests per ${context.after}. Try again later.`, + retryAfter: context.after, + }; + }, + // Allow list function to skip rate limiting for certain requests + allowList: (request: FastifyRequest) => { + // Skip rate limiting if disabled + if (!this.rateLimitConfig.enabled) return true; + // Allow health checks without rate limiting + if (request.url === '/health') return true; + return false; + }, + // Use IP address as the rate limit key + keyGenerator: (request: FastifyRequest) => { + // Use X-Forwarded-For if available (for proxied requests), otherwise use IP + const forwarded = request.headers['x-forwarded-for']; + if (forwarded) { + const ip = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(',')[0].trim(); + return ip; + } + return request.ip; + }, + }); } private setupRoutes() { @@ -267,10 +342,32 @@ export class WebServer { * - /web/mobile - Mobile web interface entry point * - /web/api/* - REST API endpoints for web clients * - /ws/web - WebSocket endpoint for real-time updates to web clients + * + * Rate limiting is applied to all web interface endpoints to prevent abuse. */ private setupWebInterfaceRoutes() { + // Rate limit configuration for GET endpoints + const getRateLimitConfig = { + config: { + rateLimit: { + max: this.rateLimitConfig.max, + timeWindow: this.rateLimitConfig.timeWindow, + }, + }, + }; + + // Rate limit configuration for POST endpoints (more restrictive) + const postRateLimitConfig = { + config: { + rateLimit: { + max: this.rateLimitConfig.maxPost, + timeWindow: this.rateLimitConfig.timeWindow, + }, + }, + }; + // Web interface root - returns info about available interfaces - this.server.get('/web', async () => { + this.server.get('/web', getRateLimitConfig, async () => { return { name: 'Maestro Web Interface', version: '1.0.0', @@ -285,7 +382,7 @@ export class WebServer { }); // Desktop web interface entry point (placeholder) - this.server.get('/web/desktop', async () => { + this.server.get('/web/desktop', getRateLimitConfig, async () => { return { message: 'Desktop web interface - Coming soon', description: 'Full-featured collaborative interface for hackathons/team coding', @@ -293,7 +390,7 @@ export class WebServer { }); // Desktop web interface with wildcard for client-side routing - this.server.get('/web/desktop/*', async () => { + this.server.get('/web/desktop/*', getRateLimitConfig, async () => { return { message: 'Desktop web interface - Coming soon', description: 'Full-featured collaborative interface for hackathons/team coding', @@ -301,7 +398,7 @@ export class WebServer { }); // Mobile web interface entry point (placeholder) - this.server.get('/web/mobile', async () => { + this.server.get('/web/mobile', getRateLimitConfig, async () => { return { message: 'Mobile web interface - Coming soon', description: 'Lightweight remote control for sending commands from your phone', @@ -309,7 +406,7 @@ export class WebServer { }); // Mobile web interface with wildcard for client-side routing - this.server.get('/web/mobile/*', async () => { + this.server.get('/web/mobile/*', getRateLimitConfig, async () => { return { message: 'Mobile web interface - Coming soon', description: 'Lightweight remote control for sending commands from your phone', @@ -317,13 +414,34 @@ export class WebServer { }); // Web API namespace root - this.server.get('/web/api', async () => { + this.server.get('/web/api', getRateLimitConfig, async () => { return { name: 'Maestro Web API', version: '1.0.0', endpoints: { sessions: '/web/api/sessions', theme: '/web/api/theme', + rateLimit: '/web/api/rate-limit', + }, + timestamp: Date.now(), + }; + }); + + // Rate limit status endpoint - allows clients to check current limits + this.server.get('/web/api/rate-limit', getRateLimitConfig, async () => { + return { + enabled: this.rateLimitConfig.enabled, + limits: { + get: { + max: this.rateLimitConfig.max, + timeWindowMs: this.rateLimitConfig.timeWindow, + timeWindowDescription: `${this.rateLimitConfig.timeWindow / 1000} seconds`, + }, + post: { + max: this.rateLimitConfig.maxPost, + timeWindowMs: this.rateLimitConfig.timeWindow, + timeWindowDescription: `${this.rateLimitConfig.timeWindow / 1000} seconds`, + }, }, timestamp: Date.now(), }; @@ -477,7 +595,7 @@ export class WebServer { }); // Authentication status endpoint - allows checking if auth is enabled - this.server.get('/web/api/auth/status', async () => { + this.server.get('/web/api/auth/status', getRateLimitConfig, async () => { return { enabled: this.authConfig.enabled, timestamp: Date.now(), @@ -485,7 +603,8 @@ export class WebServer { }); // Authentication verification endpoint - checks if a token is valid - this.server.post('/web/api/auth/verify', async (request) => { + // Uses more restrictive POST rate limit to prevent brute force attacks + this.server.post('/web/api/auth/verify', postRateLimitConfig, async (request) => { const body = request.body as { token?: string } | undefined; const token = body?.token; From e7e037fe64cc849bc25ea9322f8530b207065eb1 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 02:57:24 -0600 Subject: [PATCH 08/74] MAESTRO: Implement /api/sessions endpoint to return actual session data Updated the /api/sessions endpoint to use the getSessionsCallback to return real session data instead of an empty array placeholder. Added authentication and rate limiting to the endpoint. --- src/main/web-server.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/main/web-server.ts b/src/main/web-server.ts index 993724d5f..e4b728779 100644 --- a/src/main/web-server.ts +++ b/src/main/web-server.ts @@ -305,12 +305,21 @@ export class WebServer { })); }); - // Session list endpoint - // NOTE: Placeholder for Phase 6. Currently returns empty array. - // Future: Return actual session list from ProcessManager - this.server.get('/api/sessions', async () => { + // Session list endpoint - returns all sessions with their current states + // Rate limited using GET rate limit config + this.server.get('/api/sessions', { + preHandler: this.authenticateRequest.bind(this), + config: { + rateLimit: { + max: this.rateLimitConfig.max, + timeWindow: this.rateLimitConfig.timeWindow, + }, + }, + }, async () => { + const sessions = this.getSessionsCallback ? this.getSessionsCallback() : []; return { - sessions: [], + sessions, + count: sessions.length, timestamp: Date.now(), }; }); From 65ca472230f3594df3950a76ddd1718ba08abae3 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 02:59:49 -0600 Subject: [PATCH 09/74] MAESTRO: Implement /api/session/:id endpoint for detailed session data - Add SessionDetail interface with extended session fields (aiLogs, shellLogs, usageStats, claudeSessionId, isGitRepo) - Add GetSessionDetailCallback type and setGetSessionDetailCallback method - Implement /api/session/:id endpoint with authentication and rate limiting - Returns 404 for non-existent sessions, 503 if callback not configured - Wire up callback in index.ts to fetch session data from sessions store - Update header comments to reflect current implementation status --- src/main/index.ts | 20 +++++++++++ src/main/web-server.ts | 79 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 8bf50d437..1a0804eb0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -307,6 +307,26 @@ app.whenReady().then(() => { })); }); + // Set up callback for web server to fetch single session details + webServer.setGetSessionDetailCallback((sessionId: string) => { + const sessions = sessionsStore.get('sessions', []); + const session = sessions.find((s: any) => s.id === sessionId); + if (!session) return null; + return { + id: session.id, + name: session.name, + toolType: session.toolType, + state: session.state, + inputMode: session.inputMode, + cwd: session.cwd, + aiLogs: session.aiLogs || [], + shellLogs: session.shellLogs || [], + usageStats: session.usageStats, + claudeSessionId: session.claudeSessionId, + isGitRepo: session.isGitRepo, + }; + }); + // Set up callback for web server to fetch current theme webServer.setGetThemeCallback(() => { const themeId = store.get('activeThemeId', 'dracula'); diff --git a/src/main/web-server.ts b/src/main/web-server.ts index e4b728779..75ea463be 100644 --- a/src/main/web-server.ts +++ b/src/main/web-server.ts @@ -46,15 +46,17 @@ export interface RateLimitConfig { * Current functionality: * - Health check endpoint (/health) - WORKING * - WebSocket echo endpoint (/ws) - PLACEHOLDER (echoes messages for connectivity testing) - * - Session list endpoint (/api/sessions) - PLACEHOLDER (returns empty array) - * - Session detail endpoint (/api/sessions/:id) - PLACEHOLDER (returns stub data) + * - Session list endpoint (/api/sessions) - WORKING (returns actual session data) + * - Session detail endpoint (/api/session/:id) - WORKING (returns detailed session info) + * - Web interface WebSocket (/ws/web) - WORKING (real-time updates, authentication) + * - Authentication (token-based) - WORKING + * - Rate limiting - WORKING * * Phase 6 implementation plan: * - Integrate with ProcessManager to expose real session data * - Implement real-time session state broadcasting via WebSocket * - Stream process output to connected clients * - Handle input commands from remote clients - * - Add authentication and authorization * - Support mobile/tablet responsive UI * - Integrate with ngrok tunneling for public access * @@ -70,6 +72,28 @@ export type GetSessionsCallback = () => Array<{ cwd: string; }>; +// Session detail type for single session endpoint +export interface SessionDetail { + id: string; + name: string; + toolType: string; + state: string; + inputMode: string; + cwd: string; + aiLogs?: Array<{ timestamp: number; content: string; type?: string }>; + shellLogs?: Array<{ timestamp: number; content: string; type?: string }>; + usageStats?: { + inputTokens?: number; + outputTokens?: number; + totalCost?: number; + }; + claudeSessionId?: string; + isGitRepo?: boolean; +} + +// Callback type for fetching single session details +export type GetSessionDetailCallback = (sessionId: string) => SessionDetail | null; + // Theme type for web clients (matches renderer/types/index.ts) export interface WebTheme { id: string; @@ -111,6 +135,7 @@ export class WebServer { private authConfig: WebAuthConfig = { enabled: false, token: null }; private rateLimitConfig: RateLimitConfig = { ...DEFAULT_RATE_LIMIT_CONFIG }; private getSessionsCallback: GetSessionsCallback | null = null; + private getSessionDetailCallback: GetSessionDetailCallback | null = null; private getThemeCallback: GetThemeCallback | null = null; constructor(port: number = 8000) { @@ -133,6 +158,14 @@ export class WebServer { this.getSessionsCallback = callback; } + /** + * Set the callback function for fetching single session details + * This is called by the /api/session/:id endpoint + */ + setGetSessionDetailCallback(callback: GetSessionDetailCallback) { + this.getSessionDetailCallback = callback; + } + /** * Set the callback function for fetching current theme * This is called when a new client connects to send the initial theme @@ -324,14 +357,42 @@ export class WebServer { }; }); - // Session detail endpoint - // NOTE: Placeholder for Phase 6. Currently returns stub data. - // Future: Return actual session details including state, output, etc. - this.server.get('/api/sessions/:id', async (request) => { + // Session detail endpoint - returns detailed information for a specific session + // Rate limited using GET rate limit config + this.server.get('/api/session/:id', { + preHandler: this.authenticateRequest.bind(this), + config: { + rateLimit: { + max: this.rateLimitConfig.max, + timeWindow: this.rateLimitConfig.timeWindow, + }, + }, + }, async (request, reply) => { const { id } = request.params as { id: string }; + + if (!this.getSessionDetailCallback) { + reply.code(503).send({ + error: 'Service Unavailable', + message: 'Session detail service not configured', + timestamp: Date.now(), + }); + return; + } + + const session = this.getSessionDetailCallback(id); + + if (!session) { + reply.code(404).send({ + error: 'Not Found', + message: `Session with id '${id}' not found`, + timestamp: Date.now(), + }); + return; + } + return { - sessionId: id, - status: 'idle', + session, + timestamp: Date.now(), }; }); From 90cc71ee49f2954580ed1cc40591e175ea7738ba Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 03:01:57 -0600 Subject: [PATCH 10/74] MAESTRO: Implement /api/session/:id/send POST endpoint for sending commands Add POST endpoint to send commands to active sessions via the web API: - Add WriteToSessionCallback type for session input - Implement /api/session/:id/send endpoint with rate limiting (POST limits) - Validate command presence and type before processing - Check session exists before attempting to write - Wire up callback in index.ts using processManager.write() --- src/main/index.ts | 6 ++++ src/main/web-server.ts | 81 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/src/main/index.ts b/src/main/index.ts index 1a0804eb0..582bfaf88 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -333,6 +333,12 @@ app.whenReady().then(() => { return getThemeById(themeId); }); + // Set up callback for web server to write commands to sessions + webServer.setWriteToSessionCallback((sessionId: string, data: string) => { + if (!processManager) return false; + return processManager.write(sessionId, data); + }); + // Initialize session web server manager with callbacks sessionWebServerManager = new SessionWebServerManager( // getSessionData callback - fetch session from store diff --git a/src/main/web-server.ts b/src/main/web-server.ts index 75ea463be..5d280e280 100644 --- a/src/main/web-server.ts +++ b/src/main/web-server.ts @@ -94,6 +94,10 @@ export interface SessionDetail { // Callback type for fetching single session details export type GetSessionDetailCallback = (sessionId: string) => SessionDetail | null; +// Callback type for sending commands to a session +// Returns true if successful, false if session not found or write failed +export type WriteToSessionCallback = (sessionId: string, data: string) => boolean; + // Theme type for web clients (matches renderer/types/index.ts) export interface WebTheme { id: string; @@ -137,6 +141,7 @@ export class WebServer { private getSessionsCallback: GetSessionsCallback | null = null; private getSessionDetailCallback: GetSessionDetailCallback | null = null; private getThemeCallback: GetThemeCallback | null = null; + private writeToSessionCallback: WriteToSessionCallback | null = null; constructor(port: number = 8000) { this.port = port; @@ -174,6 +179,14 @@ export class WebServer { this.getThemeCallback = callback; } + /** + * Set the callback function for writing commands to a session + * This is called by the /api/session/:id/send endpoint + */ + setWriteToSessionCallback(callback: WriteToSessionCallback) { + this.writeToSessionCallback = callback; + } + /** * Set the authentication configuration */ @@ -396,6 +409,74 @@ export class WebServer { }; }); + // Send command to session endpoint - sends input to a specific session + // Rate limited using POST rate limit config (more restrictive) + this.server.post('/api/session/:id/send', { + preHandler: this.authenticateRequest.bind(this), + config: { + rateLimit: { + max: this.rateLimitConfig.maxPost, + timeWindow: this.rateLimitConfig.timeWindow, + }, + }, + }, async (request, reply) => { + const { id } = request.params as { id: string }; + const body = request.body as { command?: string } | undefined; + const command = body?.command; + + // Validate command is provided + if (!command || typeof command !== 'string') { + reply.code(400).send({ + error: 'Bad Request', + message: 'Command is required and must be a string', + timestamp: Date.now(), + }); + return; + } + + // Check if write callback is configured + if (!this.writeToSessionCallback) { + reply.code(503).send({ + error: 'Service Unavailable', + message: 'Session write service not configured', + timestamp: Date.now(), + }); + return; + } + + // Check if session exists first + if (this.getSessionDetailCallback) { + const session = this.getSessionDetailCallback(id); + if (!session) { + reply.code(404).send({ + error: 'Not Found', + message: `Session with id '${id}' not found`, + timestamp: Date.now(), + }); + return; + } + } + + // Write the command to the session (add newline to execute) + const success = this.writeToSessionCallback(id, command + '\n'); + + if (!success) { + reply.code(500).send({ + error: 'Internal Server Error', + message: 'Failed to send command to session', + timestamp: Date.now(), + }); + return; + } + + return { + success: true, + message: 'Command sent successfully', + sessionId: id, + timestamp: Date.now(), + }; + }); + // Setup web interface routes under /web/* namespace this.setupWebInterfaceRoutes(); } From b3f1ba660cc2b88bbeceb0c00a7abff940963ce2 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 03:04:10 -0600 Subject: [PATCH 11/74] MAESTRO: Implement /api/session/:id/interrupt POST endpoint for session interruption Add REST API endpoint to send SIGINT/Ctrl+C signal to sessions via the web interface. This allows mobile and desktop web clients to gracefully interrupt running AI agents or terminal processes. - Add InterruptSessionCallback type for session interrupt operations - Add setInterruptSessionCallback method to WebServer class - Create /api/session/:id/interrupt POST endpoint with authentication and rate limiting - Wire up callback in main process to use ProcessManager.interrupt() --- src/main/index.ts | 6 ++++ src/main/web-server.ts | 69 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/main/index.ts b/src/main/index.ts index 582bfaf88..6c7d34498 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -339,6 +339,12 @@ app.whenReady().then(() => { return processManager.write(sessionId, data); }); + // Set up callback for web server to interrupt sessions + webServer.setInterruptSessionCallback((sessionId: string) => { + if (!processManager) return false; + return processManager.interrupt(sessionId); + }); + // Initialize session web server manager with callbacks sessionWebServerManager = new SessionWebServerManager( // getSessionData callback - fetch session from store diff --git a/src/main/web-server.ts b/src/main/web-server.ts index 5d280e280..89e0342ff 100644 --- a/src/main/web-server.ts +++ b/src/main/web-server.ts @@ -98,6 +98,10 @@ export type GetSessionDetailCallback = (sessionId: string) => SessionDetail | nu // Returns true if successful, false if session not found or write failed export type WriteToSessionCallback = (sessionId: string, data: string) => boolean; +// Callback type for interrupting a session (sending SIGINT/Ctrl+C) +// Returns true if successful, false if session not found or interrupt failed +export type InterruptSessionCallback = (sessionId: string) => boolean; + // Theme type for web clients (matches renderer/types/index.ts) export interface WebTheme { id: string; @@ -142,6 +146,7 @@ export class WebServer { private getSessionDetailCallback: GetSessionDetailCallback | null = null; private getThemeCallback: GetThemeCallback | null = null; private writeToSessionCallback: WriteToSessionCallback | null = null; + private interruptSessionCallback: InterruptSessionCallback | null = null; constructor(port: number = 8000) { this.port = port; @@ -187,6 +192,14 @@ export class WebServer { this.writeToSessionCallback = callback; } + /** + * Set the callback function for interrupting a session + * This is called by the /api/session/:id/interrupt endpoint + */ + setInterruptSessionCallback(callback: InterruptSessionCallback) { + this.interruptSessionCallback = callback; + } + /** * Set the authentication configuration */ @@ -477,6 +490,62 @@ export class WebServer { }; }); + // Interrupt session endpoint - sends SIGINT/Ctrl+C to a specific session + // Rate limited using POST rate limit config (more restrictive) + this.server.post('/api/session/:id/interrupt', { + preHandler: this.authenticateRequest.bind(this), + config: { + rateLimit: { + max: this.rateLimitConfig.maxPost, + timeWindow: this.rateLimitConfig.timeWindow, + }, + }, + }, async (request, reply) => { + const { id } = request.params as { id: string }; + + // Check if interrupt callback is configured + if (!this.interruptSessionCallback) { + reply.code(503).send({ + error: 'Service Unavailable', + message: 'Session interrupt service not configured', + timestamp: Date.now(), + }); + return; + } + + // Check if session exists first + if (this.getSessionDetailCallback) { + const session = this.getSessionDetailCallback(id); + if (!session) { + reply.code(404).send({ + error: 'Not Found', + message: `Session with id '${id}' not found`, + timestamp: Date.now(), + }); + return; + } + } + + // Send interrupt signal to the session + const success = this.interruptSessionCallback(id); + + if (!success) { + reply.code(500).send({ + error: 'Internal Server Error', + message: 'Failed to interrupt session', + timestamp: Date.now(), + }); + return; + } + + return { + success: true, + message: 'Interrupt signal sent successfully', + sessionId: id, + timestamp: Date.now(), + }; + }); + // Setup web interface routes under /web/* namespace this.setupWebInterfaceRoutes(); } From 72b847840a34e0f49282460482689535cbb8a69a Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 03:05:38 -0600 Subject: [PATCH 12/74] MAESTRO: Implement /api/theme GET endpoint for current theme configuration Added a new REST API endpoint at /api/theme that returns the currently configured theme. The endpoint: - Is protected by token-based authentication (if enabled) - Is rate limited using GET rate limit config - Returns the theme object with all color values - Returns 503 if theme service not configured - Returns 404 if no theme is currently set --- src/main/web-server.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/main/web-server.ts b/src/main/web-server.ts index 89e0342ff..c11ae36bf 100644 --- a/src/main/web-server.ts +++ b/src/main/web-server.ts @@ -48,6 +48,9 @@ export interface RateLimitConfig { * - WebSocket echo endpoint (/ws) - PLACEHOLDER (echoes messages for connectivity testing) * - Session list endpoint (/api/sessions) - WORKING (returns actual session data) * - Session detail endpoint (/api/session/:id) - WORKING (returns detailed session info) + * - Session send endpoint (/api/session/:id/send) - WORKING (sends commands to session) + * - Session interrupt endpoint (/api/session/:id/interrupt) - WORKING (sends SIGINT to session) + * - Theme endpoint (/api/theme) - WORKING (returns current theme configuration) * - Web interface WebSocket (/ws/web) - WORKING (real-time updates, authentication) * - Authentication (token-based) - WORKING * - Rate limiting - WORKING @@ -490,6 +493,43 @@ export class WebServer { }; }); + // Theme endpoint - returns the current theme configuration + // Rate limited using GET rate limit config + this.server.get('/api/theme', { + preHandler: this.authenticateRequest.bind(this), + config: { + rateLimit: { + max: this.rateLimitConfig.max, + timeWindow: this.rateLimitConfig.timeWindow, + }, + }, + }, async (_request, reply) => { + if (!this.getThemeCallback) { + reply.code(503).send({ + error: 'Service Unavailable', + message: 'Theme service not configured', + timestamp: Date.now(), + }); + return; + } + + const theme = this.getThemeCallback(); + + if (!theme) { + reply.code(404).send({ + error: 'Not Found', + message: 'No theme currently configured', + timestamp: Date.now(), + }); + return; + } + + return { + theme, + timestamp: Date.now(), + }; + }); + // Interrupt session endpoint - sends SIGINT/Ctrl+C to a specific session // Rate limited using POST rate limit config (more restrictive) this.server.post('/api/session/:id/interrupt', { From 98aebe06f3db04bb636df85fec5b76e2eda85e3a Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 03:08:15 -0600 Subject: [PATCH 13/74] MAESTRO: Extract theme types to shared location for web interface - Create src/shared/theme-types.ts with Theme, ThemeId, ThemeMode, ThemeColors types - Add isValidThemeId type guard utility function - Update renderer types to re-export from shared location - Update main process themes.ts to use shared types - Update web-server.ts to import Theme from shared instead of defining WebTheme - This enables the web interface build to access theme types without duplicating code --- src/main/themes.ts | 9 +-- src/main/web-server.ts | 27 ++------- src/renderer/types/index.ts | 24 +------- src/shared/index.ts | 10 ++++ src/shared/theme-types.ts | 106 ++++++++++++++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 47 deletions(-) create mode 100644 src/shared/index.ts create mode 100644 src/shared/theme-types.ts diff --git a/src/main/themes.ts b/src/main/themes.ts index c8f484737..c529b503b 100644 --- a/src/main/themes.ts +++ b/src/main/themes.ts @@ -2,11 +2,12 @@ // This mirrors src/renderer/constants/themes.ts for use in the main process // When themes are updated in the renderer, this file should also be updated -import type { WebTheme } from './web-server'; +import type { Theme, ThemeId } from '../shared/theme-types'; -export type ThemeId = 'dracula' | 'monokai' | 'github-light' | 'solarized-light' | 'nord' | 'tokyo-night' | 'one-light' | 'gruvbox-light' | 'catppuccin-mocha' | 'gruvbox-dark' | 'catppuccin-latte' | 'ayu-light' | 'pedurple' | 'maestros-choice' | 'dre-synth' | 'inquest'; +// Re-export types from shared for convenience +export type { Theme, ThemeId } from '../shared/theme-types'; -export const THEMES: Record = { +export const THEMES: Record = { // Dark themes dracula: { id: 'dracula', @@ -320,6 +321,6 @@ export const THEMES: Record = { * Get a theme by its ID * Returns null if the theme ID is not found */ -export function getThemeById(themeId: string): WebTheme | null { +export function getThemeById(themeId: string): Theme | null { return THEMES[themeId as ThemeId] || null; } diff --git a/src/main/web-server.ts b/src/main/web-server.ts index c11ae36bf..464babbba 100644 --- a/src/main/web-server.ts +++ b/src/main/web-server.ts @@ -5,6 +5,7 @@ import rateLimit from '@fastify/rate-limit'; import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { WebSocket } from 'ws'; import crypto from 'crypto'; +import type { Theme } from '../shared/theme-types'; // Types for web client messages interface WebClientMessage { @@ -105,29 +106,11 @@ export type WriteToSessionCallback = (sessionId: string, data: string) => boolea // Returns true if successful, false if session not found or interrupt failed export type InterruptSessionCallback = (sessionId: string) => boolean; -// Theme type for web clients (matches renderer/types/index.ts) -export interface WebTheme { - id: string; - name: string; - mode: 'light' | 'dark' | 'vibe'; - colors: { - bgMain: string; - bgSidebar: string; - bgActivity: string; - border: string; - textMain: string; - textDim: string; - accent: string; - accentDim: string; - accentText: string; - success: string; - warning: string; - error: string; - }; -} +// Re-export Theme type from shared for backwards compatibility +export type { Theme } from '../shared/theme-types'; // Callback type for fetching current theme -export type GetThemeCallback = () => WebTheme | null; +export type GetThemeCallback = () => Theme | null; // Default rate limit configuration const DEFAULT_RATE_LIMIT_CONFIG: RateLimitConfig = { @@ -1002,7 +985,7 @@ export class WebServer { * Broadcast theme change to all connected web clients * Called when the user changes the theme in the desktop app */ - broadcastThemeChange(theme: WebTheme) { + broadcastThemeChange(theme: Theme) { this.broadcastToWebClients({ type: 'theme', theme, diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index c6addc381..67fa32072 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -1,34 +1,16 @@ // Type definitions for Maestro renderer +// Re-export theme types from shared location +export { Theme, ThemeId, ThemeMode, ThemeColors, isValidThemeId } from '../../shared/theme-types'; + export type ToolType = 'claude' | 'aider' | 'opencode' | 'terminal'; export type SessionState = 'idle' | 'busy' | 'waiting_input' | 'connecting' | 'error'; export type FileChangeType = 'modified' | 'added' | 'deleted'; export type RightPanelTab = 'files' | 'history' | 'scratchpad'; export type ScratchPadMode = 'raw' | 'preview' | 'wysiwyg'; -export type ThemeId = 'dracula' | 'monokai' | 'github-light' | 'solarized-light' | 'nord' | 'tokyo-night' | 'one-light' | 'gruvbox-light' | 'catppuccin-mocha' | 'gruvbox-dark' | 'catppuccin-latte' | 'ayu-light' | 'pedurple' | 'maestros-choice' | 'dre-synth' | 'inquest'; export type FocusArea = 'sidebar' | 'main' | 'right'; export type LLMProvider = 'openrouter' | 'anthropic' | 'ollama'; -export interface Theme { - id: ThemeId; - name: string; - mode: 'light' | 'dark' | 'vibe'; - colors: { - bgMain: string; - bgSidebar: string; - bgActivity: string; - border: string; - textMain: string; - textDim: string; - accent: string; - accentDim: string; - accentText: string; - success: string; - warning: string; - error: string; - }; -} - export interface Shortcut { id: string; label: string; diff --git a/src/shared/index.ts b/src/shared/index.ts new file mode 100644 index 000000000..064eb2931 --- /dev/null +++ b/src/shared/index.ts @@ -0,0 +1,10 @@ +/** + * Shared types and utilities for Maestro + * + * This module exports types that are used across multiple parts of the application: + * - Main process (Electron) + * - Renderer process (Desktop React app) + * - Web interface (Mobile and Desktop web builds) + */ + +export * from './theme-types'; diff --git a/src/shared/theme-types.ts b/src/shared/theme-types.ts new file mode 100644 index 000000000..1bc7ba715 --- /dev/null +++ b/src/shared/theme-types.ts @@ -0,0 +1,106 @@ +/** + * Shared theme type definitions for Maestro + * + * This file contains theme types used across: + * - Main process (Electron) + * - Renderer process (Desktop React app) + * - Web interface (Mobile and Desktop web builds) + * + * Keep this file dependency-free to ensure it can be imported anywhere. + */ + +/** + * Available theme identifiers + */ +export type ThemeId = + | 'dracula' + | 'monokai' + | 'github-light' + | 'solarized-light' + | 'nord' + | 'tokyo-night' + | 'one-light' + | 'gruvbox-light' + | 'catppuccin-mocha' + | 'gruvbox-dark' + | 'catppuccin-latte' + | 'ayu-light' + | 'pedurple' + | 'maestros-choice' + | 'dre-synth' + | 'inquest'; + +/** + * Theme mode indicating the overall brightness/style + */ +export type ThemeMode = 'light' | 'dark' | 'vibe'; + +/** + * Color palette for a theme + * Each color serves a specific purpose in the UI + */ +export interface ThemeColors { + /** Main background color for primary content areas */ + bgMain: string; + /** Sidebar background color */ + bgSidebar: string; + /** Background for interactive/activity elements */ + bgActivity: string; + /** Border color for dividers and outlines */ + border: string; + /** Primary text color */ + textMain: string; + /** Dimmed/secondary text color */ + textDim: string; + /** Accent color for highlights and interactive elements */ + accent: string; + /** Dimmed accent (typically with alpha transparency) */ + accentDim: string; + /** Text color for accent contexts */ + accentText: string; + /** Success state color (green tones) */ + success: string; + /** Warning state color (yellow/orange tones) */ + warning: string; + /** Error state color (red tones) */ + error: string; +} + +/** + * Complete theme definition + */ +export interface Theme { + /** Unique identifier for the theme */ + id: ThemeId; + /** Human-readable display name */ + name: string; + /** Theme mode (light, dark, or vibe) */ + mode: ThemeMode; + /** Color palette */ + colors: ThemeColors; +} + +/** + * Type guard to check if a string is a valid ThemeId + */ +export function isValidThemeId(id: string): id is ThemeId { + const validIds: ThemeId[] = [ + 'dracula', + 'monokai', + 'github-light', + 'solarized-light', + 'nord', + 'tokyo-night', + 'one-light', + 'gruvbox-light', + 'catppuccin-mocha', + 'gruvbox-dark', + 'catppuccin-latte', + 'ayu-light', + 'pedurple', + 'maestros-choice', + 'dre-synth', + 'inquest', + ]; + return validIds.includes(id as ThemeId); +} From 2ce759d8a0837b14ad8493ef966d337b85fa1971 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 03:11:02 -0600 Subject: [PATCH 14/74] MAESTRO: Create ThemeProvider component for web interface - Add src/web/components/ThemeProvider.tsx with React context for theming - Provide useTheme and useThemeColors hooks for child components - Include default theme (Dracula) for initial render before WebSocket connection - Update tsconfig.json to include src/web and src/shared directories --- src/web/components/ThemeProvider.tsx | 141 +++++++++++++++++++++++++++ src/web/components/index.ts | 13 +++ src/web/index.ts | 9 ++ tsconfig.json | 2 +- 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 src/web/components/ThemeProvider.tsx create mode 100644 src/web/components/index.ts create mode 100644 src/web/index.ts diff --git a/src/web/components/ThemeProvider.tsx b/src/web/components/ThemeProvider.tsx new file mode 100644 index 000000000..be371992d --- /dev/null +++ b/src/web/components/ThemeProvider.tsx @@ -0,0 +1,141 @@ +/** + * ThemeProvider component for Maestro web interface + * + * Provides theme context to web components. Accepts theme via props + * (typically received from WebSocket connection to desktop app). + */ + +import React, { createContext, useContext, useMemo } from 'react'; +import type { Theme, ThemeColors } from '../../shared/theme-types'; + +/** + * Context value containing the current theme and utility functions + */ +interface ThemeContextValue { + /** Current theme object */ + theme: Theme; + /** Whether the theme is a light theme */ + isLight: boolean; + /** Whether the theme is a dark theme */ + isDark: boolean; + /** Whether the theme is a vibe theme */ + isVibe: boolean; +} + +/** + * Default theme used when no theme is provided + * Matches the Dracula theme from the desktop app + */ +const defaultTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#0b0b0d', + bgSidebar: '#111113', + bgActivity: '#1c1c1f', + border: '#27272a', + textMain: '#e4e4e7', + textDim: '#a1a1aa', + accent: '#6366f1', + accentDim: 'rgba(99, 102, 241, 0.2)', + accentText: '#a5b4fc', + success: '#22c55e', + warning: '#eab308', + error: '#ef4444', + }, +}; + +const ThemeContext = createContext(null); + +export interface ThemeProviderProps { + /** Theme object to provide to children. If not provided, uses default theme. */ + theme?: Theme; + /** Children components that will have access to the theme */ + children: React.ReactNode; +} + +/** + * ThemeProvider component that provides theme context to the component tree + * + * @example + * ```tsx + * // With theme from WebSocket + * + * + * + * + * // Using the context in a child component + * const { theme, isDark } = useTheme(); + * ``` + */ +export function ThemeProvider({ theme = defaultTheme, children }: ThemeProviderProps) { + const contextValue = useMemo( + () => ({ + theme, + isLight: theme.mode === 'light', + isDark: theme.mode === 'dark', + isVibe: theme.mode === 'vibe', + }), + [theme] + ); + + return ( + + {children} + + ); +} + +/** + * Hook to access the current theme context + * + * @throws Error if used outside of a ThemeProvider + * + * @example + * ```tsx + * function MyComponent() { + * const { theme, isDark } = useTheme(); + * return ( + *
+ * {isDark ? 'Dark mode' : 'Light mode'} + *
+ * ); + * } + * ``` + */ +export function useTheme(): ThemeContextValue { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +} + +/** + * Hook to access just the theme colors for convenience + * + * @throws Error if used outside of a ThemeProvider + * + * @example + * ```tsx + * function Button() { + * const colors = useThemeColors(); + * return ( + * + * ); + * } + * ``` + */ +export function useThemeColors(): ThemeColors { + const { theme } = useTheme(); + return theme.colors; +} + +export { ThemeContext }; +export type { ThemeContextValue }; diff --git a/src/web/components/index.ts b/src/web/components/index.ts new file mode 100644 index 000000000..8b456f88b --- /dev/null +++ b/src/web/components/index.ts @@ -0,0 +1,13 @@ +/** + * Web interface components for Maestro + * + * Shared components used by both mobile and desktop web interfaces. + */ + +export { + ThemeProvider, + useTheme, + useThemeColors, + ThemeContext, +} from './ThemeProvider'; +export type { ThemeProviderProps, ThemeContextValue } from './ThemeProvider'; diff --git a/src/web/index.ts b/src/web/index.ts new file mode 100644 index 000000000..eb359b1be --- /dev/null +++ b/src/web/index.ts @@ -0,0 +1,9 @@ +/** + * Maestro Web Interface + * + * This module contains shared components, hooks, and utilities + * for the Maestro web interface (both mobile and desktop web). + */ + +// Components +export * from './components'; diff --git a/tsconfig.json b/tsconfig.json index 1f0339cf8..45b151a55 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src/renderer"] + "include": ["src/renderer", "src/web", "src/shared"] } From da95e0c92e34518b214e6d03100c0dfe43032db1 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 03:13:38 -0600 Subject: [PATCH 15/74] MAESTRO: Create CSS custom properties generator for web interface theme system Add utility module that converts theme colors to CSS custom properties: - generateCSSProperties(): Creates property map from theme - generateCSSString(): Outputs CSS with :root selector - injectCSSProperties(): Adds/updates style element in document head - removeCSSProperties(): Cleans up injected styles - setElementCSSProperties(): Applies to specific elements - cssVar(): Helper for inline style usage CSS variables use --maestro-* prefix with kebab-case naming (e.g., theme.colors.bgMain -> --maestro-bg-main). ThemeProvider now automatically injects CSS properties when theme changes, enabling CSS-based theming alongside React context. --- src/web/components/ThemeProvider.tsx | 14 +- src/web/index.ts | 3 + src/web/utils/cssCustomProperties.ts | 266 +++++++++++++++++++++++++++ src/web/utils/index.ts | 16 ++ 4 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 src/web/utils/cssCustomProperties.ts create mode 100644 src/web/utils/index.ts diff --git a/src/web/components/ThemeProvider.tsx b/src/web/components/ThemeProvider.tsx index be371992d..38fe14598 100644 --- a/src/web/components/ThemeProvider.tsx +++ b/src/web/components/ThemeProvider.tsx @@ -3,10 +3,12 @@ * * Provides theme context to web components. Accepts theme via props * (typically received from WebSocket connection to desktop app). + * Automatically injects CSS custom properties for theme colors. */ -import React, { createContext, useContext, useMemo } from 'react'; +import React, { createContext, useContext, useEffect, useMemo } from 'react'; import type { Theme, ThemeColors } from '../../shared/theme-types'; +import { injectCSSProperties, removeCSSProperties } from '../utils/cssCustomProperties'; /** * Context value containing the current theme and utility functions @@ -80,6 +82,16 @@ export function ThemeProvider({ theme = defaultTheme, children }: ThemeProviderP [theme] ); + // Inject CSS custom properties whenever the theme changes + useEffect(() => { + injectCSSProperties(theme); + + // Cleanup on unmount + return () => { + removeCSSProperties(); + }; + }, [theme]); + return ( {children} diff --git a/src/web/index.ts b/src/web/index.ts index eb359b1be..6469acaa3 100644 --- a/src/web/index.ts +++ b/src/web/index.ts @@ -7,3 +7,6 @@ // Components export * from './components'; + +// Utilities +export * from './utils'; diff --git a/src/web/utils/cssCustomProperties.ts b/src/web/utils/cssCustomProperties.ts new file mode 100644 index 000000000..817d3b7b5 --- /dev/null +++ b/src/web/utils/cssCustomProperties.ts @@ -0,0 +1,266 @@ +/** + * CSS Custom Properties Generator for Maestro Web Interface + * + * Converts theme colors to CSS custom properties (CSS variables) that can be + * injected into the DOM. This allows dynamic theme switching in the web interface. + * + * CSS variable naming convention: + * - Theme colors are prefixed with `--maestro-` + * - Color names are converted from camelCase to kebab-case + * + * Example: theme.colors.bgMain -> --maestro-bg-main + */ + +import type { Theme, ThemeColors } from '../../shared/theme-types'; + +/** + * CSS custom property name for a theme color + */ +export type ThemeCSSProperty = + | '--maestro-bg-main' + | '--maestro-bg-sidebar' + | '--maestro-bg-activity' + | '--maestro-border' + | '--maestro-text-main' + | '--maestro-text-dim' + | '--maestro-accent' + | '--maestro-accent-dim' + | '--maestro-accent-text' + | '--maestro-success' + | '--maestro-warning' + | '--maestro-error' + | '--maestro-mode'; + +/** + * Maps theme color keys to CSS custom property names + */ +const colorToCSSProperty: Record = { + bgMain: '--maestro-bg-main', + bgSidebar: '--maestro-bg-sidebar', + bgActivity: '--maestro-bg-activity', + border: '--maestro-border', + textMain: '--maestro-text-main', + textDim: '--maestro-text-dim', + accent: '--maestro-accent', + accentDim: '--maestro-accent-dim', + accentText: '--maestro-accent-text', + success: '--maestro-success', + warning: '--maestro-warning', + error: '--maestro-error', +}; + +/** + * All CSS custom property names used by the theme system + */ +export const THEME_CSS_PROPERTIES: ThemeCSSProperty[] = [ + '--maestro-bg-main', + '--maestro-bg-sidebar', + '--maestro-bg-activity', + '--maestro-border', + '--maestro-text-main', + '--maestro-text-dim', + '--maestro-accent', + '--maestro-accent-dim', + '--maestro-accent-text', + '--maestro-success', + '--maestro-warning', + '--maestro-error', + '--maestro-mode', +]; + +/** + * Generates a map of CSS custom property names to their values from a theme + * + * @param theme - The theme to generate CSS properties from + * @returns Object mapping CSS property names to values + * + * @example + * ```ts + * const props = generateCSSProperties(myTheme); + * // Returns: + * // { + * // '--maestro-bg-main': '#0b0b0d', + * // '--maestro-bg-sidebar': '#111113', + * // '--maestro-mode': 'dark', + * // ... + * // } + * ``` + */ +export function generateCSSProperties(theme: Theme): Record { + const properties: Partial> = {}; + + // Add color properties + for (const [colorKey, cssProperty] of Object.entries(colorToCSSProperty)) { + const colorValue = theme.colors[colorKey as keyof ThemeColors]; + properties[cssProperty] = colorValue; + } + + // Add mode property for CSS selectors based on theme mode + properties['--maestro-mode'] = theme.mode; + + return properties as Record; +} + +/** + * Generates a CSS string containing custom property declarations + * + * @param theme - The theme to generate CSS from + * @param selector - CSS selector to scope the variables (default: ':root') + * @returns CSS string with custom property declarations + * + * @example + * ```ts + * const css = generateCSSString(myTheme); + * // Returns: + * // `:root { + * // --maestro-bg-main: #0b0b0d; + * // --maestro-bg-sidebar: #111113; + * // ... + * // }` + * ``` + */ +export function generateCSSString(theme: Theme, selector: string = ':root'): string { + const properties = generateCSSProperties(theme); + const declarations = Object.entries(properties) + .map(([prop, value]) => ` ${prop}: ${value};`) + .join('\n'); + + return `${selector} {\n${declarations}\n}`; +} + +/** + * ID of the style element used for theme CSS properties + */ +const STYLE_ELEMENT_ID = 'maestro-theme-css-properties'; + +/** + * Injects theme CSS custom properties into the document + * + * Creates or updates a + + +
+
+
+
+
+ + + diff --git a/src/web/main.tsx b/src/web/main.tsx new file mode 100644 index 000000000..4b8dbd1e0 --- /dev/null +++ b/src/web/main.tsx @@ -0,0 +1,140 @@ +/** + * Maestro Web Interface Entry Point + * + * This is the main entry point for the web interface. + * It detects the device type and renders the appropriate interface. + */ + +import React, { StrictMode, lazy, Suspense } from 'react'; +import { createRoot } from 'react-dom/client'; +import { ThemeProvider } from './components/ThemeProvider'; +import './index.css'; + +// Lazy load mobile and desktop apps for code splitting +// These will be created in Phase 1 and Phase 2 +const MobileApp = lazy(() => + import('./mobile/App').catch(() => ({ + default: () => , + })) +); + +const DesktopApp = lazy(() => + import('./desktop/App').catch(() => ({ + default: () => , + })) +); + +/** + * Detect if the device is mobile based on screen size and touch capability + */ +function isMobileDevice(): boolean { + // Check for touch capability + const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0; + + // Check screen width (768px is a common breakpoint) + const isSmallScreen = window.innerWidth < 768; + + // Check user agent for mobile indicators + const mobileUserAgent = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + navigator.userAgent + ); + + // Consider mobile if small screen OR (has touch AND mobile user agent) + return isSmallScreen || (hasTouch && mobileUserAgent); +} + +/** + * Placeholder component shown while the actual app loads + * or if the app module hasn't been created yet + */ +function PlaceholderApp({ type }: { type: 'mobile' | 'desktop' }) { + return ( +
+

Maestro Web

+

+ {type === 'mobile' ? 'Mobile' : 'Desktop'} interface coming soon +

+

+ Connect to your Maestro desktop app to get started +

+
+ ); +} + +/** + * Loading fallback component + */ +function LoadingFallback() { + return ( +
+
+
+ ); +} + +/** + * Main App component that routes to mobile or desktop + */ +function App() { + const [isMobile, setIsMobile] = React.useState(isMobileDevice); + + // Re-check on resize (for responsive design testing) + React.useEffect(() => { + const handleResize = () => { + setIsMobile(isMobileDevice()); + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + return ( + + }> + {isMobile ? : } + + + ); +} + +// Mount the application +const container = document.getElementById('root'); +if (container) { + const root = createRoot(container); + root.render( + + + + ); +} else { + console.error('Root element not found'); +} diff --git a/src/web/mobile/App.tsx b/src/web/mobile/App.tsx new file mode 100644 index 000000000..17445d2cb --- /dev/null +++ b/src/web/mobile/App.tsx @@ -0,0 +1,142 @@ +/** + * Maestro Mobile Web App + * + * Lightweight remote control interface for mobile devices. + * Focused on quick command input and session monitoring. + * + * Phase 1 implementation will expand this component. + */ + +import React from 'react'; +import { useThemeColors } from '../components/ThemeProvider'; + +export default function MobileApp() { + const colors = useThemeColors(); + + return ( +
+ {/* Header */} +
+

Maestro

+
+ + Connecting... +
+
+ + {/* Main content area */} +
+
+

+ Mobile Remote Control +

+

+ Send commands to your AI assistants from anywhere. This interface + will be implemented in Phase 1. +

+
+

+ Make sure Maestro desktop app is running +

+
+ + {/* Bottom input bar placeholder */} +
+
+ + +
+
+
+ ); +} diff --git a/tailwind.config.mjs b/tailwind.config.mjs index 2c0d72efd..d8415d586 100644 --- a/tailwind.config.mjs +++ b/tailwind.config.mjs @@ -2,6 +2,7 @@ export default { content: [ "./src/renderer/**/*.{js,ts,jsx,tsx}", + "./src/web/**/*.{js,ts,jsx,tsx}", ], theme: { extend: { diff --git a/vite.config.web.mts b/vite.config.web.mts new file mode 100644 index 000000000..bbf76b92b --- /dev/null +++ b/vite.config.web.mts @@ -0,0 +1,111 @@ +/** + * Vite configuration for Maestro Web Interface + * + * This config builds the web interface (both mobile and desktop) + * as a standalone bundle that can be served by the Fastify server. + * + * Output: dist/web/ + */ + +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; +import { readFileSync } from 'fs'; + +// Read version from package.json +const packageJson = JSON.parse( + readFileSync(path.join(__dirname, 'package.json'), 'utf-8') +); +const appVersion = process.env.VITE_APP_VERSION || packageJson.version; + +export default defineConfig({ + plugins: [react()], + + // Entry point for web interface + root: path.join(__dirname, 'src/web'), + + // Use relative paths for assets (served from Fastify) + base: './', + + define: { + __APP_VERSION__: JSON.stringify(appVersion), + }, + + resolve: { + alias: { + // Allow importing from renderer types/constants + '@renderer': path.join(__dirname, 'src/renderer'), + '@web': path.join(__dirname, 'src/web'), + '@shared': path.join(__dirname, 'src/shared'), + }, + }, + + build: { + outDir: path.join(__dirname, 'dist/web'), + emptyOutDir: true, + + // Generate source maps for debugging + sourcemap: true, + + rollupOptions: { + input: { + // Single entry point that handles routing to mobile/desktop + main: path.join(__dirname, 'src/web/index.html'), + }, + output: { + // Organize output by type + entryFileNames: 'assets/[name]-[hash].js', + chunkFileNames: 'assets/[name]-[hash].js', + assetFileNames: 'assets/[name]-[hash].[ext]', + + // Manual chunking for better caching + manualChunks: { + // React core in its own chunk + react: ['react', 'react-dom'], + }, + }, + }, + + // Target modern browsers (web interface doesn't need legacy support) + target: 'es2020', + + // Minimize bundle size + minify: 'esbuild', + + // Report chunk sizes + reportCompressedSize: true, + }, + + // Development server (for testing web interface standalone) + server: { + port: 5174, // Different from renderer dev server (5173) + strictPort: true, + // Proxy API calls to the running Maestro app during development + proxy: { + '/api': { + target: 'http://localhost:45678', + changeOrigin: true, + }, + '/ws': { + target: 'ws://localhost:45678', + ws: true, + }, + }, + }, + + // Preview server for testing production build + preview: { + port: 5175, + strictPort: true, + }, + + // Enable CSS code splitting + css: { + devSourcemap: true, + }, + + // Optimize dependencies + optimizeDeps: { + include: ['react', 'react-dom'], + }, +}); From a22c2b20bfcfd40eaa3a00d4cd2457e4e95117cd Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 03:33:05 -0600 Subject: [PATCH 23/74] MAESTRO: Add npm run build:web script for web interface bundling --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index aa8c59a7d..f6d84dadd 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "build": "npm run build:main && npm run build:renderer", "build:main": "tsc -p tsconfig.main.json", "build:renderer": "vite build", + "build:web": "vite build --config vite.config.web.mts", "package": "npm run build && electron-builder --mac --win --linux", "package:mac": "npm run build && electron-builder --mac", "package:win": "npm run build && electron-builder --win", From 1ad7daf43517749309842b0eb40625e6455fed11 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 03:34:39 -0600 Subject: [PATCH 24/74] MAESTRO: Integrate web build into main build process - Add build:web step to main build script chain - Add dev:web script for standalone web development server - Web assets now built automatically during npm run build - Packaged app will include dist/web/* via existing files glob --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index f6d84dadd..d64bbceed 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"", "dev:main": "tsc -p tsconfig.main.json && NODE_ENV=development electron .", "dev:renderer": "vite", - "build": "npm run build:main && npm run build:renderer", + "dev:web": "vite --config vite.config.web.mts", + "build": "npm run build:main && npm run build:renderer && npm run build:web", "build:main": "tsc -p tsconfig.main.json", "build:renderer": "vite build", "build:web": "vite build --config vite.config.web.mts", From 304544eeef6e59007216f2742b50e2582763210b Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 03:37:05 -0600 Subject: [PATCH 25/74] MAESTRO: Serve built web assets from Fastify static handler - Install @fastify/static for serving static files - Add resolveWebAssetsPath method to find web assets in dist/web - Register @fastify/static plugin to serve assets at /web/assets/ - Add serveIndexHtml helper that transforms asset paths for SPA - Update /web/desktop and /web/mobile routes to serve index.html - Transform relative ./assets/ paths to /web/assets/ for proper routing --- package-lock.json | 299 +++++++++++++++++++++++++++++++++++++---- package.json | 1 + src/main/web-server.ts | 115 +++++++++++++--- 3 files changed, 365 insertions(+), 50 deletions(-) diff --git a/package-lock.json b/package-lock.json index e797bac58..81a535283 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@emoji-mart/react": "^1.1.1", "@fastify/cors": "^8.5.0", "@fastify/rate-limit": "^10.3.0", + "@fastify/static": "^8.3.0", "@fastify/websocket": "^9.0.0", "@types/dompurify": "^3.0.5", "ansi-to-html": "^0.7.2", @@ -1121,6 +1122,22 @@ "node": ">=12" } }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/ajv-compiler": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", @@ -1241,6 +1258,168 @@ ], "license": "MIT" }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/send/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@fastify/static": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-8.3.0.tgz", + "integrity": "sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^0.5.4", + "fastify-plugin": "^5.0.0", + "fastq": "^1.17.1", + "glob": "^11.0.0" + } + }, + "node_modules/@fastify/static/node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/static/node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@fastify/static/node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@fastify/static/node_modules/lru-cache": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@fastify/static/node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@fastify/static/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@fastify/static/node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@fastify/websocket": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-9.0.0.tgz", @@ -1281,11 +1460,31 @@ "mlly": "^1.7.4" } }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -1303,7 +1502,6 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1316,7 +1514,6 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1329,14 +1526,12 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, "license": "MIT" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -1354,7 +1549,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -1370,7 +1564,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -2689,7 +2882,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2699,7 +2891,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3807,7 +3998,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3820,7 +4010,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/color-support": { @@ -4073,6 +4262,18 @@ "dev": true, "license": "ISC" }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4149,7 +4350,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4871,6 +5071,15 @@ "dev": true, "license": "MIT" }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -5130,7 +5339,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, "license": "MIT" }, "node_modules/ejs": { @@ -5494,7 +5702,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encoding": { @@ -5648,6 +5855,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -6000,7 +6213,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -6640,6 +6852,26 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -6913,7 +7145,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7028,7 +7259,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/jackspeak": { @@ -9271,7 +9501,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/package-manager-detector": { @@ -9334,7 +9563,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10504,6 +10732,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shallow-equal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-3.1.0.tgz", @@ -10514,7 +10748,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -10527,7 +10760,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10550,7 +10782,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -10758,6 +10989,15 @@ "node": ">= 6" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/stream-shift": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", @@ -10777,7 +11017,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -10793,7 +11032,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -10822,7 +11060,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -10836,7 +11073,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -11205,6 +11441,15 @@ "node": ">=12" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -11688,7 +11933,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -11733,7 +11977,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", diff --git a/package.json b/package.json index d64bbceed..c6f22dceb 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "@emoji-mart/react": "^1.1.1", "@fastify/cors": "^8.5.0", "@fastify/rate-limit": "^10.3.0", + "@fastify/static": "^8.3.0", "@fastify/websocket": "^9.0.0", "@types/dompurify": "^3.0.5", "ansi-to-html": "^0.7.2", diff --git a/src/main/web-server.ts b/src/main/web-server.ts index 464babbba..f88bb3def 100644 --- a/src/main/web-server.ts +++ b/src/main/web-server.ts @@ -2,9 +2,12 @@ import Fastify from 'fastify'; import cors from '@fastify/cors'; import websocket from '@fastify/websocket'; import rateLimit from '@fastify/rate-limit'; +import fastifyStatic from '@fastify/static'; import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { WebSocket } from 'ws'; import crypto from 'crypto'; +import path from 'path'; +import { existsSync, readFileSync } from 'fs'; import type { Theme } from '../shared/theme-types'; // Types for web client messages @@ -133,6 +136,7 @@ export class WebServer { private getThemeCallback: GetThemeCallback | null = null; private writeToSessionCallback: WriteToSessionCallback | null = null; private interruptSessionCallback: InterruptSessionCallback | null = null; + private webAssetsPath: string | null = null; constructor(port: number = 8000) { this.port = port; @@ -142,10 +146,79 @@ export class WebServer { }, }); + // Determine web assets path (production vs development) + this.webAssetsPath = this.resolveWebAssetsPath(); + this.setupMiddleware(); this.setupRoutes(); } + /** + * Resolve the path to web assets + * In production: dist/web relative to app root + * In development: same location but might not exist until built + */ + private resolveWebAssetsPath(): string | null { + // Try multiple locations for the web assets + const possiblePaths = [ + // Production: relative to the compiled main process + path.join(__dirname, '..', 'web'), + // Development: from project root + path.join(process.cwd(), 'dist', 'web'), + // Alternative: relative to __dirname going up to dist + path.join(__dirname, 'web'), + ]; + + for (const p of possiblePaths) { + if (existsSync(path.join(p, 'index.html'))) { + console.log(`Web assets found at: ${p}`); + return p; + } + } + + console.warn('Web assets not found. Web interface will not be served. Run "npm run build:web" to build web assets.'); + return null; + } + + /** + * Serve the index.html file for SPA routes + * Rewrites asset paths to work from the /web prefix + */ + private serveIndexHtml(reply: FastifyReply): void { + if (!this.webAssetsPath) { + reply.code(503).send({ + error: 'Service Unavailable', + message: 'Web interface not built. Run "npm run build:web" to build web assets.', + }); + return; + } + + const indexPath = path.join(this.webAssetsPath, 'index.html'); + if (!existsSync(indexPath)) { + reply.code(404).send({ + error: 'Not Found', + message: 'Web interface index.html not found.', + }); + return; + } + + try { + // Read and transform the HTML to fix asset paths + let html = readFileSync(indexPath, 'utf-8'); + + // Transform relative asset paths (./assets/) to use the /web/assets/ prefix + html = html.replace(/\.\/assets\//g, '/web/assets/'); + + reply.type('text/html').send(html); + } catch (err) { + console.error('Error serving index.html:', err); + reply.code(500).send({ + error: 'Internal Server Error', + message: 'Failed to serve web interface.', + }); + } + } + /** * Set the callback function for fetching current sessions list * This is called when a new client connects to send the initial state @@ -318,6 +391,16 @@ export class WebServer { return request.ip; }, }); + + // Serve static web assets if the build directory exists + if (this.webAssetsPath) { + await this.server.register(fastifyStatic, { + root: this.webAssetsPath, + prefix: '/web/assets/', + decorateReply: false, // Don't decorate reply to avoid conflicts + }); + console.log(`Serving web assets from ${this.webAssetsPath}`); + } } private setupRoutes() { @@ -624,36 +707,24 @@ export class WebServer { }; }); - // Desktop web interface entry point (placeholder) - this.server.get('/web/desktop', getRateLimitConfig, async () => { - return { - message: 'Desktop web interface - Coming soon', - description: 'Full-featured collaborative interface for hackathons/team coding', - }; + // Desktop web interface entry point - serves the SPA + this.server.get('/web/desktop', getRateLimitConfig, async (_request, reply) => { + this.serveIndexHtml(reply); }); // Desktop web interface with wildcard for client-side routing - this.server.get('/web/desktop/*', getRateLimitConfig, async () => { - return { - message: 'Desktop web interface - Coming soon', - description: 'Full-featured collaborative interface for hackathons/team coding', - }; + this.server.get('/web/desktop/*', getRateLimitConfig, async (_request, reply) => { + this.serveIndexHtml(reply); }); - // Mobile web interface entry point (placeholder) - this.server.get('/web/mobile', getRateLimitConfig, async () => { - return { - message: 'Mobile web interface - Coming soon', - description: 'Lightweight remote control for sending commands from your phone', - }; + // Mobile web interface entry point - serves the SPA + this.server.get('/web/mobile', getRateLimitConfig, async (_request, reply) => { + this.serveIndexHtml(reply); }); // Mobile web interface with wildcard for client-side routing - this.server.get('/web/mobile/*', getRateLimitConfig, async () => { - return { - message: 'Mobile web interface - Coming soon', - description: 'Lightweight remote control for sending commands from your phone', - }; + this.server.get('/web/mobile/*', getRateLimitConfig, async (_request, reply) => { + this.serveIndexHtml(reply); }); // Web API namespace root From 037f55e3cb5b43856fa0236ab84d4f06050a71b2 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 03:40:14 -0600 Subject: [PATCH 26/74] MAESTRO: Configure code splitting for mobile vs desktop web bundles Updated vite.config.web.mts to use dynamic chunk naming and manual chunking function for better code splitting: - Mobile and desktop apps now build into separate named chunks - React dependencies are isolated in their own chunk for caching - Dynamic chunkFileNames function preserves mobile/desktop naming - Added webpackChunkName magic comments in main.tsx for clarity Build output now shows distinct bundles: mobile-*.js, desktop-*.js, main-*.js, and react-*.js for optimal lazy loading. --- src/web/main.tsx | 7 ++++--- vite.config.web.mts | 51 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/web/main.tsx b/src/web/main.tsx index 4b8dbd1e0..60796cfa8 100644 --- a/src/web/main.tsx +++ b/src/web/main.tsx @@ -11,15 +11,16 @@ import { ThemeProvider } from './components/ThemeProvider'; import './index.css'; // Lazy load mobile and desktop apps for code splitting -// These will be created in Phase 1 and Phase 2 +// Using webpackChunkName magic comments for Vite compatibility +// This creates separate bundles that are only loaded based on device type const MobileApp = lazy(() => - import('./mobile/App').catch(() => ({ + import(/* webpackChunkName: "mobile" */ './mobile/App').catch(() => ({ default: () => , })) ); const DesktopApp = lazy(() => - import('./desktop/App').catch(() => ({ + import(/* webpackChunkName: "desktop" */ './desktop/App').catch(() => ({ default: () => , })) ); diff --git a/vite.config.web.mts b/vite.config.web.mts index bbf76b92b..f9bdf0693 100644 --- a/vite.config.web.mts +++ b/vite.config.web.mts @@ -55,13 +55,54 @@ export default defineConfig({ output: { // Organize output by type entryFileNames: 'assets/[name]-[hash].js', - chunkFileNames: 'assets/[name]-[hash].js', + // Use dynamic chunk names that preserve mobile/desktop distinction + chunkFileNames: (chunkInfo) => { + // Preserve mobile/desktop naming for their respective chunks + if (chunkInfo.name?.includes('mobile') || chunkInfo.facadeModuleId?.includes('/mobile/')) { + return 'assets/mobile-[hash].js'; + } + if (chunkInfo.name?.includes('desktop') || chunkInfo.facadeModuleId?.includes('/desktop/')) { + return 'assets/desktop-[hash].js'; + } + // Named chunks (react, vendor) keep their names + if (chunkInfo.name && !chunkInfo.name.startsWith('_')) { + return `assets/${chunkInfo.name}-[hash].js`; + } + return 'assets/[name]-[hash].js'; + }, assetFileNames: 'assets/[name]-[hash].[ext]', - // Manual chunking for better caching - manualChunks: { - // React core in its own chunk - react: ['react', 'react-dom'], + // Manual chunking for better caching and code splitting + manualChunks: (id) => { + // React core in its own chunk for optimal caching + if (id.includes('node_modules/react-dom')) { + return 'react'; + } + if (id.includes('node_modules/react/') || id.includes('node_modules/react-is')) { + return 'react'; + } + // Scheduler is a React dependency + if (id.includes('node_modules/scheduler')) { + return 'react'; + } + + // Mobile-specific dependencies (future-proofing for Phase 1) + // When mobile-specific libraries are added, they'll be bundled separately + if (id.includes('/mobile/') && !id.includes('node_modules')) { + return 'mobile'; + } + + // Desktop-specific dependencies (future-proofing for Phase 2) + // When desktop-specific libraries are added, they'll be bundled separately + if (id.includes('/desktop/') && !id.includes('node_modules')) { + return 'desktop'; + } + + // Shared web components stay in main bundle or get split automatically + // This allows React.lazy() to create async chunks for mobile/desktop + + // Return undefined for other modules to let Rollup handle them + return undefined; }, }, }, From 3769944070ec0b6acd91f9ca2698a33955b01d27 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 03:42:08 -0600 Subject: [PATCH 27/74] MAESTRO: Create mobile entry point with utilities and configuration Add src/web/mobile/index.tsx as the proper module entry point for the mobile web interface. This includes: - Re-exports of MobileApp component for cleaner imports - Mobile configuration options (haptics, voice input, offline queue) - Mobile viewport breakpoint constants - Safe area padding defaults for notched devices - Haptic feedback utilities with pattern presets - Gesture threshold constants for swipe/pull-to-refresh detection - Voice input support detection Updated main.tsx to import from './mobile' instead of './mobile/App' to leverage the new entry point for better code organization. --- src/web/main.tsx | 2 +- src/web/mobile/index.tsx | 140 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 src/web/mobile/index.tsx diff --git a/src/web/main.tsx b/src/web/main.tsx index 60796cfa8..19ac23a96 100644 --- a/src/web/main.tsx +++ b/src/web/main.tsx @@ -14,7 +14,7 @@ import './index.css'; // Using webpackChunkName magic comments for Vite compatibility // This creates separate bundles that are only loaded based on device type const MobileApp = lazy(() => - import(/* webpackChunkName: "mobile" */ './mobile/App').catch(() => ({ + import(/* webpackChunkName: "mobile" */ './mobile').catch(() => ({ default: () => , })) ); diff --git a/src/web/mobile/index.tsx b/src/web/mobile/index.tsx new file mode 100644 index 000000000..c21de793e --- /dev/null +++ b/src/web/mobile/index.tsx @@ -0,0 +1,140 @@ +/** + * Maestro Mobile Web Entry Point + * + * This is the entry point for the mobile web interface. + * It exports the main MobileApp component and any mobile-specific + * utilities needed for the remote control interface. + * + * The mobile interface is designed for: + * - Quick command input from your phone + * - Session monitoring and status checking + * - Lightweight interaction when away from desk + * + * This module can be directly imported for code splitting: + * ```typescript + * const Mobile = lazy(() => import('./mobile')); + * ``` + */ + +import React from 'react'; +import MobileApp from './App'; + +// Re-export the main app component as both default and named +export { MobileApp }; +export default MobileApp; + +/** + * Mobile-specific configuration options + */ +export interface MobileConfig { + /** Enable haptic feedback for interactions (if supported) */ + enableHaptics?: boolean; + /** Enable voice input button */ + enableVoiceInput?: boolean; + /** Enable offline command queue */ + enableOfflineQueue?: boolean; + /** Maximum lines for expandable input (default: 4) */ + maxInputLines?: number; + /** Enable pull-to-refresh gesture */ + enablePullToRefresh?: boolean; +} + +/** + * Default mobile configuration + */ +export const defaultMobileConfig: MobileConfig = { + enableHaptics: true, + enableVoiceInput: true, + enableOfflineQueue: true, + maxInputLines: 4, + enablePullToRefresh: true, +}; + +/** + * Mobile viewport constants + */ +export const MOBILE_BREAKPOINTS = { + /** Maximum width for small phones */ + small: 320, + /** Maximum width for standard phones */ + medium: 375, + /** Maximum width for large phones / small tablets */ + large: 428, + /** Maximum width considered "mobile" */ + max: 768, +} as const; + +/** + * Safe area padding values (for notched devices) + * These are CSS env() fallback values in pixels + */ +export const SAFE_AREA_DEFAULTS = { + top: 44, + bottom: 34, + left: 0, + right: 0, +} as const; + +/** + * Check if the current viewport is mobile-sized + */ +export function isMobileViewport(): boolean { + if (typeof window === 'undefined') return false; + return window.innerWidth <= MOBILE_BREAKPOINTS.max; +} + +/** + * Check if the device supports haptic feedback + */ +export function supportsHaptics(): boolean { + if (typeof window === 'undefined') return false; + return 'vibrate' in navigator; +} + +/** + * Trigger haptic feedback (if supported and enabled) + * @param pattern - Vibration pattern in milliseconds + */ +export function triggerHaptic(pattern: number | number[] = 10): void { + if (supportsHaptics()) { + navigator.vibrate(pattern); + } +} + +/** + * Haptic patterns for different interactions + */ +export const HAPTIC_PATTERNS = { + /** Light tap for button presses */ + tap: 10, + /** Medium feedback for sends */ + send: [10, 30, 10], + /** Strong feedback for interrupts */ + interrupt: [50, 30, 50], + /** Success pattern */ + success: [10, 50, 20], + /** Error pattern */ + error: [100, 30, 100, 30, 100], +} as const; + +/** + * Check if the device supports the Web Speech API for voice input + */ +export function supportsVoiceInput(): boolean { + if (typeof window === 'undefined') return false; + return 'webkitSpeechRecognition' in window || 'SpeechRecognition' in window; +} + +/** + * Mobile gesture detection utilities + */ +export const GESTURE_THRESHOLDS = { + /** Minimum distance (px) for swipe detection */ + swipeDistance: 50, + /** Maximum time (ms) for swipe gesture */ + swipeTime: 300, + /** Distance (px) for pull-to-refresh trigger */ + pullToRefresh: 80, + /** Long press duration (ms) */ + longPress: 500, +} as const; From 7cc54a88fe8efbcb20a4d5178f911be795f47efb Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 03:44:40 -0600 Subject: [PATCH 28/74] MAESTRO: Add mobile app shell with dynamic connection status header - Integrate useWebSocket hook for real-time connection state - Add MobileHeader component with Badge-based status indicator - Display different UI states: disconnected, connecting, connected - Enable retry button when disconnected - Use proper safe-area insets for notched devices (env()) - Enable/disable input controls based on connection state --- src/web/mobile/App.tsx | 288 +++++++++++++++++++++++++++++++++-------- 1 file changed, 234 insertions(+), 54 deletions(-) diff --git a/src/web/mobile/App.tsx b/src/web/mobile/App.tsx index 17445d2cb..bce26cee4 100644 --- a/src/web/mobile/App.tsx +++ b/src/web/mobile/App.tsx @@ -7,87 +7,259 @@ * Phase 1 implementation will expand this component. */ -import React from 'react'; +import React, { useEffect, useCallback } from 'react'; import { useThemeColors } from '../components/ThemeProvider'; +import { useWebSocket, type WebSocketState } from '../hooks/useWebSocket'; +import { Badge, type BadgeVariant } from '../components/Badge'; -export default function MobileApp() { +/** + * Map WebSocket state to display properties + */ +interface ConnectionStatusConfig { + label: string; + variant: BadgeVariant; + pulse: boolean; +} + +const CONNECTION_STATUS_CONFIG: Record = { + disconnected: { + label: 'Disconnected', + variant: 'error', + pulse: false, + }, + connecting: { + label: 'Connecting...', + variant: 'connecting', + pulse: true, + }, + authenticating: { + label: 'Authenticating...', + variant: 'connecting', + pulse: true, + }, + connected: { + label: 'Connected', + variant: 'success', + pulse: false, + }, + authenticated: { + label: 'Connected', + variant: 'success', + pulse: false, + }, +}; + +/** + * Header component for the mobile app + * Displays app title and connection status indicator + */ +interface MobileHeaderProps { + connectionState: WebSocketState; + onRetry?: () => void; +} + +function MobileHeader({ connectionState, onRetry }: MobileHeaderProps) { const colors = useThemeColors(); + const statusConfig = CONNECTION_STATUS_CONFIG[connectionState]; return ( -
- {/* Header */} -
+ Maestro + +
-

Maestro

+ + {statusConfig.label} + +
+
+ ); +} + +/** + * Main mobile app component with WebSocket connection management + */ +export default function MobileApp() { + const colors = useThemeColors(); + + const { state: connectionState, connect, error, reconnectAttempts } = useWebSocket({ + autoReconnect: true, + maxReconnectAttempts: 10, + reconnectDelay: 2000, + handlers: { + onConnectionChange: (newState) => { + console.log('[Mobile] Connection state:', newState); + }, + onError: (err) => { + console.error('[Mobile] WebSocket error:', err); + }, + }, + }); + + // Connect on mount + useEffect(() => { + connect(); + }, [connect]); + + // Retry connection handler + const handleRetry = useCallback(() => { + connect(); + }, [connect]); + + // Determine content based on connection state + const renderContent = () => { + if (connectionState === 'disconnected') { + return (
- + Connection Lost + +

+ {error || 'Unable to connect to Maestro desktop app.'} +

+ {reconnectAttempts > 0 && ( +

+ Reconnection attempts: {reconnectAttempts} +

+ )} +
- + ); + } - {/* Main content area */} -
+ if (connectionState === 'connecting' || connectionState === 'authenticating') { + return (
-

- Mobile Remote Control +

+ Connecting to Maestro...

-

- Send commands to your AI assistants from anywhere. This interface - will be implemented in Phase 1. +

+ Please wait while we establish a connection to your desktop app.

-

+ ); + } + + // Connected or authenticated state + return ( +

+

+ Mobile Remote Control +

+

+ Send commands to your AI assistants from anywhere. Session selector + and command input will be added next. +

+
+ ); + }; + + // CSS variable for dynamic viewport height with fallback + const containerStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + minHeight: '100dvh', + backgroundColor: colors.bgMain, + color: colors.textMain, + }; + + return ( +
+ {/* Header with connection status */} + + + {/* Main content area */} +
+ {renderContent()} +

Make sure Maestro desktop app is running

@@ -96,10 +268,10 @@ export default function MobileApp() {
+ + {/* Animation keyframes */} + + + ); +} + /** * Props for the SessionPillBar component */ @@ -130,10 +514,19 @@ export interface SessionPillBarProps { style?: React.CSSProperties; } +/** + * Popover state interface + */ +interface PopoverState { + session: Session; + anchorRect: DOMRect; +} + /** * SessionPillBar component * * Renders a horizontally scrollable bar of session pills for the mobile interface. + * Supports long-press on pills to show session info popover. * * @example * ```tsx @@ -153,6 +546,17 @@ export function SessionPillBar({ }: SessionPillBarProps) { const colors = useThemeColors(); const scrollContainerRef = useRef(null); + const [popoverState, setPopoverState] = useState(null); + + // Handle long-press on a session pill + const handleLongPress = useCallback((session: Session, rect: DOMRect) => { + setPopoverState({ session, anchorRect: rect }); + }, []); + + // Close the popover + const handleClosePopover = useCallback(() => { + setPopoverState(null); + }, []); // Scroll active session into view when it changes useEffect(() => { @@ -202,57 +606,69 @@ export function SessionPillBar({ } return ( -
- {/* Scrollable container */} + <>
- {sessions.map((session) => ( -
- -
- ))} + {/* Scrollable container */} +
+ {sessions.map((session) => ( +
+ +
+ ))} +
+ + {/* Inline style for hiding scrollbar */} +
- {/* Inline style for hiding scrollbar */} - -
+ {/* Session info popover */} + {popoverState && ( + + )} + ); } From 5c529492bdd685f93e813b6ccaf4915db5105f9c Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 04:10:11 -0600 Subject: [PATCH 35/74] MAESTRO: Add collapsible group headers to mobile session pill bar - Extended session data to include groupId, groupName, and groupEmoji - Updated web server types and callbacks to pass group info to web clients - Updated useWebSocket SessionData interface with group fields - Updated useSessions hook with GroupInfo type for proper group organization - Added GroupHeader component with tap-to-collapse functionality - SessionPillBar now organizes sessions by group with collapsible headers - Group headers show emoji, name, session count, and collapse indicator - Groups sorted alphabetically with Ungrouped always at the end - Group headers only shown when multiple groups exist --- src/main/index.ts | 24 ++-- src/main/web-server.ts | 9 ++ src/web/hooks/useSessions.ts | 37 +++-- src/web/hooks/useWebSocket.ts | 3 + src/web/mobile/SessionPillBar.tsx | 216 +++++++++++++++++++++++++++--- 5 files changed, 253 insertions(+), 36 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 6c7d34498..fa029be2a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -297,14 +297,22 @@ app.whenReady().then(() => { // Set up callback for web server to fetch sessions list webServer.setGetSessionsCallback(() => { const sessions = sessionsStore.get('sessions', []); - return sessions.map((s: any) => ({ - id: s.id, - name: s.name, - toolType: s.toolType, - state: s.state, - inputMode: s.inputMode, - cwd: s.cwd, - })); + const groups = groupsStore.get('groups', []); + return sessions.map((s: any) => { + // Find the group for this session + const group = s.groupId ? groups.find((g: any) => g.id === s.groupId) : null; + return { + id: s.id, + name: s.name, + toolType: s.toolType, + state: s.state, + inputMode: s.inputMode, + cwd: s.cwd, + groupId: s.groupId || null, + groupName: group?.name || null, + groupEmoji: group?.emoji || null, + }; + }); }); // Set up callback for web server to fetch single session details diff --git a/src/main/web-server.ts b/src/main/web-server.ts index f88bb3def..cb3ead7b2 100644 --- a/src/main/web-server.ts +++ b/src/main/web-server.ts @@ -77,6 +77,9 @@ export type GetSessionsCallback = () => Array<{ state: string; inputMode: string; cwd: string; + groupId: string | null; + groupName: string | null; + groupEmoji: string | null; }>; // Session detail type for single session endpoint @@ -1014,6 +1017,9 @@ export class WebServer { state: string; inputMode: string; cwd: string; + groupId?: string | null; + groupName?: string | null; + groupEmoji?: string | null; }) { this.broadcastToWebClients({ type: 'session_added', @@ -1044,6 +1050,9 @@ export class WebServer { state: string; inputMode: string; cwd: string; + groupId?: string | null; + groupName?: string | null; + groupEmoji?: string | null; }>) { this.broadcastToWebClients({ type: 'sessions_list', diff --git a/src/web/hooks/useSessions.ts b/src/web/hooks/useSessions.ts index bfece21f9..0fbc0edf5 100644 --- a/src/web/hooks/useSessions.ts +++ b/src/web/hooks/useSessions.ts @@ -61,11 +61,21 @@ export interface UseSessionsOptions extends Omit; + /** Sessions organized by group (keyed by groupId or 'ungrouped') */ + sessionsByGroup: Record; /** Currently active/selected session */ activeSession: Session | null; /** Set the active session by ID */ @@ -305,17 +315,26 @@ export function useSessions(options: UseSessionsOptions = {}): UseSessionsReturn ); /** - * Sessions organized by group (tool type as a simple grouping) + * Sessions organized by group (using actual group data from server) + * Groups are keyed by groupId (or 'ungrouped' for sessions without a group) */ - const sessionsByGroup = useMemo(() => { - const groups: Record = {}; + const sessionsByGroup = useMemo((): Record => { + const groups: Record = {}; + for (const session of sessions) { - const group = session.toolType || 'other'; - if (!groups[group]) { - groups[group] = []; + const groupKey = session.groupId || 'ungrouped'; + + if (!groups[groupKey]) { + groups[groupKey] = { + id: session.groupId || null, + name: session.groupName || 'Ungrouped', + emoji: session.groupEmoji || null, + sessions: [], + }; } - groups[group].push(session); + groups[groupKey].sessions.push(session); } + return groups; }, [sessions]); diff --git a/src/web/hooks/useWebSocket.ts b/src/web/hooks/useWebSocket.ts index 2d2b06041..b0dd530ca 100644 --- a/src/web/hooks/useWebSocket.ts +++ b/src/web/hooks/useWebSocket.ts @@ -23,6 +23,9 @@ export interface SessionData { state: string; inputMode: string; cwd: string; + groupId?: string | null; + groupName?: string | null; + groupEmoji?: string | null; } /** diff --git a/src/web/mobile/SessionPillBar.tsx b/src/web/mobile/SessionPillBar.tsx index b6564eece..da01bcad1 100644 --- a/src/web/mobile/SessionPillBar.tsx +++ b/src/web/mobile/SessionPillBar.tsx @@ -11,12 +11,13 @@ * - Mode indicator (AI vs Terminal) * - Active session highlighting * - Long-press to show session info popover + * - Group name display above session pills with collapsible groups */ -import React, { useRef, useEffect, useCallback, useState } from 'react'; +import React, { useRef, useEffect, useCallback, useState, useMemo } from 'react'; import { useThemeColors } from '../components/ThemeProvider'; import { StatusDot, type SessionStatus } from '../components/Badge'; -import type { Session } from '../hooks/useSessions'; +import type { Session, GroupInfo } from '../hooks/useSessions'; import { triggerHaptic, HAPTIC_PATTERNS } from './index'; /** Duration in ms to trigger long-press */ @@ -498,6 +499,101 @@ function SessionInfoPopover({ session, anchorRect, onClose }: SessionInfoPopover ); } +/** + * Props for the group header component + */ +interface GroupHeaderProps { + groupId: string; + name: string; + emoji: string | null; + sessionCount: number; + isCollapsed: boolean; + onToggleCollapse: (groupId: string) => void; +} + +/** + * Group header component that displays group name with collapse/expand toggle + */ +function GroupHeader({ + groupId, + name, + emoji, + sessionCount, + isCollapsed, + onToggleCollapse, +}: GroupHeaderProps) { + const colors = useThemeColors(); + + const handleClick = useCallback(() => { + triggerHaptic(HAPTIC_PATTERNS.tap); + onToggleCollapse(groupId); + }, [groupId, onToggleCollapse]); + + return ( + + ); +} + /** * Props for the SessionPillBar component */ @@ -526,6 +622,7 @@ interface PopoverState { * SessionPillBar component * * Renders a horizontally scrollable bar of session pills for the mobile interface. + * Sessions are organized by groups with collapsible group headers. * Supports long-press on pills to show session info popover. * * @example @@ -547,6 +644,44 @@ export function SessionPillBar({ const colors = useThemeColors(); const scrollContainerRef = useRef(null); const [popoverState, setPopoverState] = useState(null); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + + // Organize sessions by group + const sessionsByGroup = useMemo((): Record => { + const groups: Record = {}; + + for (const session of sessions) { + const groupKey = session.groupId || 'ungrouped'; + + if (!groups[groupKey]) { + groups[groupKey] = { + id: session.groupId || null, + name: session.groupName || 'Ungrouped', + emoji: session.groupEmoji || null, + sessions: [], + }; + } + groups[groupKey].sessions.push(session); + } + + return groups; + }, [sessions]); + + // Get sorted group keys (ungrouped last) + const sortedGroupKeys = useMemo(() => { + const keys = Object.keys(sessionsByGroup); + return keys.sort((a, b) => { + // Put 'ungrouped' at the end + if (a === 'ungrouped') return 1; + if (b === 'ungrouped') return -1; + // Sort others alphabetically by group name + return sessionsByGroup[a].name.localeCompare(sessionsByGroup[b].name); + }); + }, [sessionsByGroup]); + + // Check if there are multiple groups (to decide whether to show group headers) + const hasMultipleGroups = sortedGroupKeys.length > 1 || + (sortedGroupKeys.length === 1 && sortedGroupKeys[0] !== 'ungrouped'); // Handle long-press on a session pill const handleLongPress = useCallback((session: Session, rect: DOMRect) => { @@ -558,6 +693,19 @@ export function SessionPillBar({ setPopoverState(null); }, []); + // Toggle group collapsed state + const handleToggleCollapse = useCallback((groupId: string) => { + setCollapsedGroups(prev => { + const next = new Set(prev); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + }, []); + // Scroll active session into view when it changes useEffect(() => { if (!activeSessionId || !scrollContainerRef.current) return; @@ -632,24 +780,54 @@ export function SessionPillBar({ // Hide scrollbar using inline style (for webkit browsers) className="hide-scrollbar" role="tablist" - aria-label="Session selector. Long press a session for details." + aria-label="Session selector organized by groups. Long press a session for details." > - {sessions.map((session) => ( -
- -
- ))} + {sortedGroupKeys.map((groupKey) => { + const group = sessionsByGroup[groupKey]; + const isCollapsed = collapsedGroups.has(groupKey); + const showGroupHeader = hasMultipleGroups; + + return ( + + {/* Group header (only show if multiple groups exist) */} + {showGroupHeader && ( +
+ +
+ )} + + {/* Session pills (hidden when collapsed) */} + {!isCollapsed && group.sessions.map((session) => ( +
+ +
+ ))} +
+ ); + })}
{/* Inline style for hiding scrollbar */} From 73daa7662db662243b6ce0eb5f0c33a090722f0c Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 04:14:03 -0600 Subject: [PATCH 36/74] MAESTRO: Add All Sessions view with larger cards for mobile web When there are many sessions (>5), an "All Sessions" button appears in the session pill bar. Tapping it opens a full-screen view with: - Larger, more detailed session cards showing name, status, tool type, mode, and working directory - Collapsible group sections for organized navigation - Search/filter functionality to quickly find sessions - Session selection that closes the view and returns to main interface The AllSessionsView component is integrated into the mobile app and exported from the mobile module for reuse. --- src/web/mobile/AllSessionsView.tsx | 643 +++++++++++++++++++++++++++++ src/web/mobile/App.tsx | 24 ++ src/web/mobile/SessionPillBar.tsx | 61 +++ src/web/mobile/index.tsx | 8 +- 4 files changed, 734 insertions(+), 2 deletions(-) create mode 100644 src/web/mobile/AllSessionsView.tsx diff --git a/src/web/mobile/AllSessionsView.tsx b/src/web/mobile/AllSessionsView.tsx new file mode 100644 index 000000000..643a86de0 --- /dev/null +++ b/src/web/mobile/AllSessionsView.tsx @@ -0,0 +1,643 @@ +/** + * AllSessionsView component for Maestro mobile web interface + * + * A full-screen view displaying all sessions as larger cards. + * This view is triggered when: + * - User has many sessions (default threshold: 6+) + * - User taps "All Sessions" button in the session pill bar + * + * Features: + * - Larger, touch-friendly session cards + * - Sessions organized by group with collapsible group headers + * - Status indicator, mode badge, and working directory visible + * - Swipe down to dismiss / back button at top + * - Search/filter sessions + */ + +import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'; +import { useThemeColors } from '../components/ThemeProvider'; +import { StatusDot, type SessionStatus } from '../components/Badge'; +import type { Session, GroupInfo } from '../hooks/useSessions'; +import { triggerHaptic, HAPTIC_PATTERNS } from './index'; + +/** + * Session card component for the All Sessions view + * Larger and more detailed than the session pills + */ +interface SessionCardProps { + session: Session; + isActive: boolean; + onSelect: (sessionId: string) => void; +} + +function MobileSessionCard({ session, isActive, onSelect }: SessionCardProps) { + const colors = useThemeColors(); + + // Map session state to status for StatusDot + const getStatus = (): SessionStatus => { + const state = session.state as string; + if (state === 'idle') return 'idle'; + if (state === 'busy') return 'busy'; + if (state === 'connecting') return 'connecting'; + return 'error'; + }; + + // Get status label + const getStatusLabel = (): string => { + const state = session.state as string; + if (state === 'idle') return 'Ready'; + if (state === 'busy') return 'Thinking...'; + if (state === 'connecting') return 'Connecting...'; + return 'Error'; + }; + + // Get tool type display name + const getToolTypeLabel = (): string => { + const toolTypeMap: Record = { + 'claude-code': 'Claude Code', + 'aider-gemini': 'Aider (Gemini)', + 'qwen-coder': 'Qwen Coder', + 'terminal': 'Terminal', + }; + return toolTypeMap[session.toolType] || session.toolType; + }; + + // Truncate path for display + const truncatePath = (path: string, maxLength: number = 40): string => { + if (path.length <= maxLength) return path; + const parts = path.split('/'); + if (parts.length <= 2) return `...${path.slice(-maxLength + 3)}`; + return `.../${parts.slice(-2).join('/')}`; + }; + + const handleClick = useCallback(() => { + triggerHaptic(HAPTIC_PATTERNS.tap); + onSelect(session.id); + }, [session.id, onSelect]); + + return ( + + ); +} + +/** + * Group section component with collapsible header + */ +interface GroupSectionProps { + groupId: string; + name: string; + emoji: string | null; + sessions: Session[]; + activeSessionId: string | null; + onSelectSession: (sessionId: string) => void; + isCollapsed: boolean; + onToggleCollapse: (groupId: string) => void; +} + +function GroupSection({ + groupId, + name, + emoji, + sessions, + activeSessionId, + onSelectSession, + isCollapsed, + onToggleCollapse, +}: GroupSectionProps) { + const colors = useThemeColors(); + + const handleToggle = useCallback(() => { + triggerHaptic(HAPTIC_PATTERNS.tap); + onToggleCollapse(groupId); + }, [groupId, onToggleCollapse]); + + return ( +
+ {/* Group header */} + + + {/* Session cards */} + {!isCollapsed && ( +
+ {sessions.map((session) => ( + + ))} +
+ )} +
+ ); +} + +/** + * Props for AllSessionsView component + */ +export interface AllSessionsViewProps { + /** List of sessions to display */ + sessions: Session[]; + /** ID of the currently active session */ + activeSessionId: string | null; + /** Callback when a session is selected */ + onSelectSession: (sessionId: string) => void; + /** Callback to close the All Sessions view */ + onClose: () => void; + /** Optional filter/search query */ + searchQuery?: string; +} + +/** + * AllSessionsView component + * + * Full-screen view showing all sessions as larger cards, organized by group. + * Provides better visibility when there are many sessions. + */ +export function AllSessionsView({ + sessions, + activeSessionId, + onSelectSession, + onClose, + searchQuery = '', +}: AllSessionsViewProps) { + const colors = useThemeColors(); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); + const containerRef = useRef(null); + + // Filter sessions by search query + const filteredSessions = useMemo(() => { + if (!localSearchQuery.trim()) return sessions; + const query = localSearchQuery.toLowerCase(); + return sessions.filter( + (session) => + session.name.toLowerCase().includes(query) || + session.cwd.toLowerCase().includes(query) || + (session.toolType && session.toolType.toLowerCase().includes(query)) + ); + }, [sessions, localSearchQuery]); + + // Organize sessions by group + const sessionsByGroup = useMemo((): Record => { + const groups: Record = {}; + + for (const session of filteredSessions) { + const groupKey = session.groupId || 'ungrouped'; + + if (!groups[groupKey]) { + groups[groupKey] = { + id: session.groupId || null, + name: session.groupName || 'Ungrouped', + emoji: session.groupEmoji || null, + sessions: [], + }; + } + groups[groupKey].sessions.push(session); + } + + return groups; + }, [filteredSessions]); + + // Get sorted group keys (ungrouped last) + const sortedGroupKeys = useMemo(() => { + const keys = Object.keys(sessionsByGroup); + return keys.sort((a, b) => { + if (a === 'ungrouped') return 1; + if (b === 'ungrouped') return -1; + return sessionsByGroup[a].name.localeCompare(sessionsByGroup[b].name); + }); + }, [sessionsByGroup]); + + // Toggle group collapse + const handleToggleCollapse = useCallback((groupId: string) => { + setCollapsedGroups((prev) => { + const next = new Set(prev); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + }, []); + + // Handle session selection and close view + const handleSelectSession = useCallback( + (sessionId: string) => { + onSelectSession(sessionId); + onClose(); + }, + [onSelectSession, onClose] + ); + + // Handle close button + const handleClose = useCallback(() => { + triggerHaptic(HAPTIC_PATTERNS.tap); + onClose(); + }, [onClose]); + + // Handle search input change + const handleSearchChange = useCallback((e: React.ChangeEvent) => { + setLocalSearchQuery(e.target.value); + }, []); + + // Clear search + const handleClearSearch = useCallback(() => { + setLocalSearchQuery(''); + }, []); + + // Close on escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + return ( +
+ {/* Header */} +
+

+ All Sessions +

+ +
+ + {/* Search bar */} +
+
+ {/* Search icon */} + 🔍 + + {localSearchQuery && ( + + )} +
+
+ + {/* Session list */} +
+ {filteredSessions.length === 0 ? ( +
+

+ {localSearchQuery ? 'No sessions found' : 'No sessions available'} +

+

+ {localSearchQuery + ? `No sessions match "${localSearchQuery}"` + : 'Create a session in the desktop app to get started'} +

+
+ ) : sortedGroupKeys.length === 1 && sortedGroupKeys[0] === 'ungrouped' ? ( + // If only ungrouped sessions, render without group header +
+ {filteredSessions.map((session) => ( + + ))} +
+ ) : ( + // Render with group sections + sortedGroupKeys.map((groupKey) => { + const group = sessionsByGroup[groupKey]; + return ( + + ); + }) + )} +
+ + {/* Animation keyframes */} + +
+ ); +} + +export default AllSessionsView; diff --git a/src/web/mobile/App.tsx b/src/web/mobile/App.tsx index 5bf94e6f5..54fa80146 100644 --- a/src/web/mobile/App.tsx +++ b/src/web/mobile/App.tsx @@ -16,6 +16,7 @@ import { usePullToRefresh } from '../hooks/usePullToRefresh'; import { useOfflineStatus } from '../main'; import { triggerHaptic, HAPTIC_PATTERNS } from './index'; import { SessionPillBar } from './SessionPillBar'; +import { AllSessionsView } from './AllSessionsView'; import type { Session } from '../hooks/useSessions'; /** @@ -132,6 +133,7 @@ export default function MobileApp() { const [lastRefreshTime, setLastRefreshTime] = useState(null); const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(null); + const [showAllSessions, setShowAllSessions] = useState(false); const { state: connectionState, connect, send, error, reconnectAttempts } = useWebSocket({ autoReconnect: true, @@ -224,6 +226,17 @@ export default function MobileApp() { triggerHaptic(HAPTIC_PATTERNS.tap); }, []); + // Handle opening All Sessions view + const handleOpenAllSessions = useCallback(() => { + setShowAllSessions(true); + triggerHaptic(HAPTIC_PATTERNS.tap); + }, []); + + // Handle closing All Sessions view + const handleCloseAllSessions = useCallback(() => { + setShowAllSessions(false); + }, []); + // Determine content based on connection state const renderContent = () => { // Show offline state when device has no network connectivity @@ -368,6 +381,17 @@ export default function MobileApp() { sessions={sessions} activeSessionId={activeSessionId} onSelectSession={handleSelectSession} + onOpenAllSessions={handleOpenAllSessions} + /> + )} + + {/* All Sessions view - full-screen modal with larger session cards */} + {showAllSessions && ( + )} diff --git a/src/web/mobile/SessionPillBar.tsx b/src/web/mobile/SessionPillBar.tsx index da01bcad1..46a8fb314 100644 --- a/src/web/mobile/SessionPillBar.tsx +++ b/src/web/mobile/SessionPillBar.tsx @@ -594,6 +594,9 @@ function GroupHeader({ ); } +/** Threshold for showing "All Sessions" button (when session count exceeds this) */ +export const ALL_SESSIONS_THRESHOLD = 5; + /** * Props for the SessionPillBar component */ @@ -604,6 +607,10 @@ export interface SessionPillBarProps { activeSessionId: string | null; /** Callback when a session is selected */ onSelectSession: (sessionId: string) => void; + /** Callback to open the All Sessions view */ + onOpenAllSessions?: () => void; + /** Whether to show the All Sessions button (shows automatically if sessions > threshold) */ + showAllSessionsButton?: boolean; /** Optional className for additional styling */ className?: string; /** Optional inline styles */ @@ -638,6 +645,8 @@ export function SessionPillBar({ sessions, activeSessionId, onSelectSession, + onOpenAllSessions, + showAllSessionsButton, className = '', style, }: SessionPillBarProps) { @@ -683,6 +692,17 @@ export function SessionPillBar({ const hasMultipleGroups = sortedGroupKeys.length > 1 || (sortedGroupKeys.length === 1 && sortedGroupKeys[0] !== 'ungrouped'); + // Determine if we should show the "All Sessions" button + const shouldShowAllSessionsButton = showAllSessionsButton !== undefined + ? showAllSessionsButton + : sessions.length > ALL_SESSIONS_THRESHOLD; + + // Handle "All Sessions" button click + const handleOpenAllSessions = useCallback(() => { + triggerHaptic(HAPTIC_PATTERNS.tap); + onOpenAllSessions?.(); + }, [onOpenAllSessions]); + // Handle long-press on a session pill const handleLongPress = useCallback((session: Session, rect: DOMRect) => { setPopoverState({ session, anchorRect: rect }); @@ -828,6 +848,47 @@ export function SessionPillBar({ ); })} + + {/* "All Sessions" button - shown when many sessions or explicitly enabled */} + {shouldShowAllSessionsButton && onOpenAllSessions && ( +
+ +
+ )}
{/* Inline style for hiding scrollbar */} diff --git a/src/web/mobile/index.tsx b/src/web/mobile/index.tsx index 2bddfd802..6f929b75b 100644 --- a/src/web/mobile/index.tsx +++ b/src/web/mobile/index.tsx @@ -18,14 +18,18 @@ import React from 'react'; import MobileApp from './App'; -import { SessionPillBar, type SessionPillBarProps } from './SessionPillBar'; +import { SessionPillBar, type SessionPillBarProps, ALL_SESSIONS_THRESHOLD } from './SessionPillBar'; +import { AllSessionsView, type AllSessionsViewProps } from './AllSessionsView'; // Re-export the main app component as both default and named export { MobileApp }; export default MobileApp; // Re-export session pill bar component -export { SessionPillBar, type SessionPillBarProps }; +export { SessionPillBar, type SessionPillBarProps, ALL_SESSIONS_THRESHOLD }; + +// Re-export All Sessions view component +export { AllSessionsView, type AllSessionsViewProps }; /** * Mobile-specific configuration options From 7c039637abdf5a4952e7de86e676b62070df9ce6 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 04:16:32 -0600 Subject: [PATCH 37/74] MAESTRO: Add sticky bottom CommandInputBar for mobile web interface - Create new CommandInputBar component with Visual Viewport API support for proper keyboard handling on mobile devices - Component stays fixed at bottom and adjusts position when keyboard appears - Uses 16px font size to prevent iOS zoom on focus - Integrates with existing theme system and session state - Add bottom padding to main container to account for fixed input bar - Export CommandInputBar from mobile module index --- src/web/mobile/App.tsx | 93 +++++----- src/web/mobile/CommandInputBar.tsx | 276 +++++++++++++++++++++++++++++ src/web/mobile/index.tsx | 4 + 3 files changed, 320 insertions(+), 53 deletions(-) create mode 100644 src/web/mobile/CommandInputBar.tsx diff --git a/src/web/mobile/App.tsx b/src/web/mobile/App.tsx index 54fa80146..fdb695c9d 100644 --- a/src/web/mobile/App.tsx +++ b/src/web/mobile/App.tsx @@ -17,6 +17,7 @@ import { useOfflineStatus } from '../main'; import { triggerHaptic, HAPTIC_PATTERNS } from './index'; import { SessionPillBar } from './SessionPillBar'; import { AllSessionsView } from './AllSessionsView'; +import { CommandInputBar } from './CommandInputBar'; import type { Session } from '../hooks/useSessions'; /** @@ -134,6 +135,7 @@ export default function MobileApp() { const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(null); const [showAllSessions, setShowAllSessions] = useState(false); + const [commandInput, setCommandInput] = useState(''); const { state: connectionState, connect, send, error, reconnectAttempts } = useWebSocket({ autoReconnect: true, @@ -237,6 +239,31 @@ export default function MobileApp() { setShowAllSessions(false); }, []); + // Handle command submission + const handleCommandSubmit = useCallback((command: string) => { + if (!activeSessionId) return; + + // Provide haptic feedback on send + triggerHaptic(HAPTIC_PATTERNS.send); + + // Send the command to the active session + send({ + type: 'send_command', + sessionId: activeSessionId, + command, + }); + + // Clear the input + setCommandInput(''); + + console.log('[Mobile] Command sent:', command, 'to session:', activeSessionId); + }, [activeSessionId, send]); + + // Handle command input change + const handleCommandChange = useCallback((value: string) => { + setCommandInput(value); + }, []); + // Determine content based on connection state const renderContent = () => { // Show offline state when device has no network connectivity @@ -353,12 +380,15 @@ export default function MobileApp() { }; // CSS variable for dynamic viewport height with fallback + // The fixed CommandInputBar requires padding at the bottom of the container const containerStyle: React.CSSProperties = { display: 'flex', flexDirection: 'column', minHeight: '100dvh', backgroundColor: colors.bgMain, color: colors.textMain, + // Add padding at bottom to account for fixed input bar (~70px + safe area) + paddingBottom: 'calc(70px + max(12px, env(safe-area-inset-bottom)))', }; // Determine if session pill bar should be shown @@ -456,59 +486,16 @@ export default function MobileApp() { - {/* Bottom input bar placeholder */} -
-
- - -
-
+ {/* Sticky bottom command input bar */} + ); } diff --git a/src/web/mobile/CommandInputBar.tsx b/src/web/mobile/CommandInputBar.tsx new file mode 100644 index 000000000..5291938f2 --- /dev/null +++ b/src/web/mobile/CommandInputBar.tsx @@ -0,0 +1,276 @@ +/** + * CommandInputBar - Sticky bottom input bar for mobile web interface + * + * A touch-friendly command input component that stays fixed at the bottom + * of the viewport and properly handles mobile keyboard appearance. + * + * Features: + * - Always visible at bottom of screen + * - Adjusts position when mobile keyboard appears (using visualViewport API) + * - Supports safe area insets for notched devices + * - Disabled state when disconnected or offline + */ + +import React, { useRef, useEffect, useState, useCallback } from 'react'; +import { useThemeColors } from '../components/ThemeProvider'; + +export interface CommandInputBarProps { + /** Whether the device is offline */ + isOffline: boolean; + /** Whether connected to the server */ + isConnected: boolean; + /** Placeholder text for the input */ + placeholder?: string; + /** Callback when command is submitted */ + onSubmit?: (command: string) => void; + /** Callback when input value changes */ + onChange?: (value: string) => void; + /** Current input value (controlled) */ + value?: string; + /** Whether the input is disabled */ + disabled?: boolean; +} + +/** + * CommandInputBar component + * + * Provides a sticky bottom input bar optimized for mobile devices. + * Uses the Visual Viewport API to stay above the keyboard. + */ +export function CommandInputBar({ + isOffline, + isConnected, + placeholder, + onSubmit, + onChange, + value: controlledValue, + disabled: externalDisabled, +}: CommandInputBarProps) { + const colors = useThemeColors(); + const inputRef = useRef(null); + const containerRef = useRef(null); + + // Track keyboard visibility for positioning + const [keyboardOffset, setKeyboardOffset] = useState(0); + const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); + + // Internal state for uncontrolled mode + const [internalValue, setInternalValue] = useState(''); + const value = controlledValue !== undefined ? controlledValue : internalValue; + + // Determine if input should be disabled + const isDisabled = externalDisabled || isOffline || !isConnected; + + // Get placeholder text based on state + const getPlaceholder = () => { + if (isOffline) return 'Offline...'; + if (!isConnected) return 'Connecting...'; + return placeholder || 'Enter command...'; + }; + + /** + * Handle Visual Viewport resize for keyboard detection + * This is the modern way to handle mobile keyboard appearance + */ + useEffect(() => { + const viewport = window.visualViewport; + if (!viewport) return; + + const handleResize = () => { + // Calculate the offset caused by keyboard + const windowHeight = window.innerHeight; + const viewportHeight = viewport.height; + const offset = windowHeight - viewportHeight - viewport.offsetTop; + + // Only update if there's a significant change (keyboard appearing/disappearing) + if (offset > 50) { + setKeyboardOffset(offset); + setIsKeyboardVisible(true); + } else { + setKeyboardOffset(0); + setIsKeyboardVisible(false); + } + }; + + const handleScroll = () => { + // Re-adjust on scroll to keep the bar in view + if (containerRef.current && isKeyboardVisible) { + // Force the container to stay at the bottom of the visible area + handleResize(); + } + }; + + viewport.addEventListener('resize', handleResize); + viewport.addEventListener('scroll', handleScroll); + + // Initial check + handleResize(); + + return () => { + viewport.removeEventListener('resize', handleResize); + viewport.removeEventListener('scroll', handleScroll); + }; + }, [isKeyboardVisible]); + + /** + * Handle input change + */ + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value; + if (controlledValue === undefined) { + setInternalValue(newValue); + } + onChange?.(newValue); + }, + [controlledValue, onChange] + ); + + /** + * Handle form submission + */ + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + if (!value.trim() || isDisabled) return; + + onSubmit?.(value.trim()); + + // Clear input after submit (for uncontrolled mode) + if (controlledValue === undefined) { + setInternalValue(''); + } + + // Keep focus on input after submit + inputRef.current?.focus(); + }, + [value, isDisabled, onSubmit, controlledValue] + ); + + /** + * Handle key press events + */ + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Submit on Enter (without shift for single line) + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + }, + [handleSubmit] + ); + + return ( +
+
+ {/* Command input field */} + + + {/* Send button */} + +
+
+ ); +} + +export default CommandInputBar; diff --git a/src/web/mobile/index.tsx b/src/web/mobile/index.tsx index 6f929b75b..0fc554266 100644 --- a/src/web/mobile/index.tsx +++ b/src/web/mobile/index.tsx @@ -20,6 +20,7 @@ import React from 'react'; import MobileApp from './App'; import { SessionPillBar, type SessionPillBarProps, ALL_SESSIONS_THRESHOLD } from './SessionPillBar'; import { AllSessionsView, type AllSessionsViewProps } from './AllSessionsView'; +import { CommandInputBar, type CommandInputBarProps } from './CommandInputBar'; // Re-export the main app component as both default and named export { MobileApp }; @@ -31,6 +32,9 @@ export { SessionPillBar, type SessionPillBarProps, ALL_SESSIONS_THRESHOLD }; // Re-export All Sessions view component export { AllSessionsView, type AllSessionsViewProps }; +// Re-export command input bar component +export { CommandInputBar, type CommandInputBarProps }; + /** * Mobile-specific configuration options */ From b3b1a2428da7ee663a836d07ad72b3ab9606fbdd Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 04:19:14 -0600 Subject: [PATCH 38/74] MAESTRO: Add large touch-friendly textarea input for mobile command bar Replace single-line input with multi-line textarea optimized for mobile: - Larger touch target (48px min height) meeting Apple HIG guidelines - 17px font size for readability while preventing iOS zoom - 2px border with accent color focus ring for better accessibility - Tactile feedback on send button with scale animation - Support for Shift+Enter to add newlines (Enter submits) - Larger send button (48x48px) with 24px icon --- src/web/mobile/CommandInputBar.tsx | 106 +++++++++++++++++++++-------- 1 file changed, 78 insertions(+), 28 deletions(-) diff --git a/src/web/mobile/CommandInputBar.tsx b/src/web/mobile/CommandInputBar.tsx index 5291938f2..c607fda6a 100644 --- a/src/web/mobile/CommandInputBar.tsx +++ b/src/web/mobile/CommandInputBar.tsx @@ -9,11 +9,22 @@ * - Adjusts position when mobile keyboard appears (using visualViewport API) * - Supports safe area insets for notched devices * - Disabled state when disconnected or offline + * - Large touch-friendly textarea for easy mobile input + * - Minimum 44px touch targets per Apple HIG guidelines */ import React, { useRef, useEffect, useState, useCallback } from 'react'; import { useThemeColors } from '../components/ThemeProvider'; +/** Minimum touch target size per Apple HIG guidelines (44pt) */ +const MIN_TOUCH_TARGET = 44; + +/** Default minimum height for the text input area */ +const MIN_INPUT_HEIGHT = 48; + +/** Line height for text calculations */ +const LINE_HEIGHT = 22; + export interface CommandInputBarProps { /** Whether the device is offline */ isOffline: boolean; @@ -47,7 +58,7 @@ export function CommandInputBar({ disabled: externalDisabled, }: CommandInputBarProps) { const colors = useThemeColors(); - const inputRef = useRef(null); + const textareaRef = useRef(null); const containerRef = useRef(null); // Track keyboard visibility for positioning @@ -113,10 +124,10 @@ export function CommandInputBar({ }, [isKeyboardVisible]); /** - * Handle input change + * Handle textarea change */ const handleChange = useCallback( - (e: React.ChangeEvent) => { + (e: React.ChangeEvent) => { const newValue = e.target.value; if (controlledValue === undefined) { setInternalValue(newValue); @@ -141,18 +152,19 @@ export function CommandInputBar({ setInternalValue(''); } - // Keep focus on input after submit - inputRef.current?.focus(); + // Keep focus on textarea after submit + textareaRef.current?.focus(); }, [value, isDisabled, onSubmit, controlledValue] ); /** * Handle key press events + * Enter submits, Shift+Enter adds a newline */ const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - // Submit on Enter (without shift for single line) + (e: React.KeyboardEvent) => { + // Submit on Enter (Shift+Enter adds newline for multi-line input) if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(e); @@ -185,16 +197,15 @@ export function CommandInputBar({ onSubmit={handleSubmit} style={{ display: 'flex', - gap: '8px', - alignItems: 'center', + gap: '12px', + alignItems: 'flex-end', // Align to bottom for multi-line textarea paddingLeft: '16px', paddingRight: '16px', }} > - {/* Command input field */} - { + // Add focus ring for accessibility + e.currentTarget.style.borderColor = colors.accent; + e.currentTarget.style.boxShadow = `0 0 0 3px ${colors.accent}33`; + }} + onBlur={(e) => { + // Remove focus ring + e.currentTarget.style.borderColor = colors.border; + e.currentTarget.style.boxShadow = 'none'; }} aria-label="Command input" aria-disabled={isDisabled} + aria-multiline="true" /> - {/* Send button */} + {/* Send button - large touch target matching input height */} + {/* Large touch-friendly command textarea */}