From bca5d4a919d05b21680a6552ee3d8ebe686f5576 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 17 Mar 2026 18:01:32 +0100 Subject: [PATCH 01/57] MAESTRO: add web UX parity shared types and request-response protocol Add 11 new TypeScript interfaces/types and 18 callback type signatures to web-server/types.ts for settings, groups, auto-run docs, file tree, git status/diff, and notifications. Add sendRequest() to useWebSocket hook enabling promise-based request-response over WebSocket with requestId correlation, timeout handling, and connection-loss rejection. Co-Authored-By: Claude Opus 4.6 --- src/main/web-server/types.ts | 163 ++++++++++++++++++++++++++++++++++ src/web/hooks/useWebSocket.ts | 62 +++++++++++++ 2 files changed, 225 insertions(+) diff --git a/src/main/web-server/types.ts b/src/main/web-server/types.ts index d03df966b1..40583fef93 100644 --- a/src/main/web-server/types.ts +++ b/src/main/web-server/types.ts @@ -329,3 +329,166 @@ export type GetHistoryCallback = ( * Callback to get all connected web clients. */ export type GetWebClientsCallback = () => Map; + +// ============================================================================= +// Web UX Parity Types +// ============================================================================= + +/** + * Union type for setting values exposed to web. + */ +export type SettingValue = string | number | boolean | null; + +/** + * Curated subset of settings exposed to the web interface. + */ +export interface WebSettings { + theme: string; + fontSize: number; + enterToSendAI: boolean; + enterToSendTerminal: boolean; + defaultSaveToHistory: boolean; + defaultShowThinking: string; + autoScroll: boolean; + notificationsEnabled: boolean; + audioFeedbackEnabled: boolean; + colorBlindMode: string; + conductorProfile: string; +} + +/** + * Group info for web. + */ +export interface GroupData { + id: string; + name: string; + emoji: string | null; + sessionIds: string[]; +} + +/** + * Auto Run document metadata. + */ +export interface AutoRunDocument { + filename: string; + path: string; + taskCount: number; + completedCount: number; +} + +/** + * File tree entry. + */ +export interface FileTreeNode { + name: string; + path: string; + isDirectory: boolean; + children?: FileTreeNode[]; + size?: number; +} + +/** + * File content response. + */ +export interface FileContentResult { + content: string; + language: string; + size: number; + truncated: boolean; +} + +/** + * Git status entry. + */ +export interface GitStatusFile { + path: string; + status: string; + staged: boolean; +} + +/** + * Git status response. + */ +export interface GitStatusResult { + branch: string; + files: GitStatusFile[]; + ahead: number; + behind: number; +} + +/** + * Git diff response. + */ +export interface GitDiffResult { + diff: string; + files: string[]; +} + +/** + * Notification preferences configuration. + */ +export interface NotificationPreferences { + agentComplete: boolean; + agentError: boolean; + autoRunComplete: boolean; + autoRunTaskComplete: boolean; + contextWarning: boolean; + soundEnabled: boolean; +} + +/** + * Notification broadcast payload. + */ +export interface NotificationEvent { + eventType: + | 'agent_complete' + | 'agent_error' + | 'autorun_complete' + | 'autorun_task_complete' + | 'context_warning'; + sessionId: string; + sessionName: string; + message: string; + severity: 'info' | 'warning' | 'error'; +} + +// ============================================================================= +// Web UX Parity Callback Types +// ============================================================================= + +export type GetSettingsCallback = () => WebSettings; +export type SetSettingCallback = (key: string, value: SettingValue) => Promise; +export type GetGroupsCallback = () => GroupData[]; +export type CreateGroupCallback = (name: string, emoji?: string) => Promise<{ id: string } | null>; +export type RenameGroupCallback = (groupId: string, name: string) => Promise; +export type DeleteGroupCallback = (groupId: string) => Promise; +export type MoveSessionToGroupCallback = ( + sessionId: string, + groupId: string | null +) => Promise; +export type CreateSessionCallback = ( + name: string, + toolType: string, + cwd: string, + groupId?: string +) => Promise<{ sessionId: string } | null>; +export type DeleteSessionCallback = (sessionId: string) => Promise; +export type RenameSessionCallback = (sessionId: string, newName: string) => Promise; +export type GetAutoRunDocsCallback = (sessionId: string) => Promise; +export type GetAutoRunDocContentCallback = ( + sessionId: string, + filename: string +) => Promise; +export type SaveAutoRunDocCallback = ( + sessionId: string, + filename: string, + content: string +) => Promise; +export type StopAutoRunCallback = (sessionId: string) => Promise; +export type GetFileTreeCallback = (sessionId: string, subPath?: string) => Promise; +export type GetFileContentCallback = ( + sessionId: string, + filePath: string +) => Promise; +export type GetGitStatusCallback = (sessionId: string) => Promise; +export type GetGitDiffCallback = (sessionId: string, filePath?: string) => Promise; diff --git a/src/web/hooks/useWebSocket.ts b/src/web/hooks/useWebSocket.ts index dd3ecc5b0e..34ea3571b9 100644 --- a/src/web/hooks/useWebSocket.ts +++ b/src/web/hooks/useWebSocket.ts @@ -419,6 +419,8 @@ export interface UseWebSocketReturn { ping: () => void; /** Send a raw message to the server */ send: (message: object) => boolean; + /** Send a request and wait for a correlated response */ + sendRequest: (type: string, payload?: Record, timeoutMs?: number) => Promise; } /** @@ -501,6 +503,13 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet const seenMsgIdsRef = useRef>(new Set()); // Ref for handleMessage to avoid stale closure issues const handleMessageRef = useRef<((event: MessageEvent) => void) | null>(null); + // Pending request-response map for sendRequest correlation + const pendingRequestsRef = useRef< + Map< + string, + { resolve: (data: any) => void; reject: (err: Error) => void; timer: ReturnType } + > + >(new Map()); // Keep handlers ref up to date SYNCHRONOUSLY to avoid race conditions // This must happen before any WebSocket messages are processed @@ -541,6 +550,16 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet try { const message = JSON.parse(event.data) as TypedServerMessage; + // Check for request-response correlation before dispatching + const requestId = (message as any).requestId as string | undefined; + if (requestId && pendingRequestsRef.current.has(requestId)) { + const pending = pendingRequestsRef.current.get(requestId)!; + clearTimeout(pending.timer); + pendingRequestsRef.current.delete(requestId); + pending.resolve(message); + return; + } + // Debug: Log all incoming messages (not just session_output) console.log( `[WebSocket] Message received: type=${message.type}`, @@ -798,6 +817,12 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet webLogger.error('WebSocket connection error', 'WebSocket', event); setError('WebSocket connection error'); handlersRef.current?.onError?.('WebSocket connection error'); + // Reject all pending requests + for (const [, pending] of pendingRequestsRef.current) { + clearTimeout(pending.timer); + pending.reject(new Error('Connection lost')); + } + pendingRequestsRef.current.clear(); }; ws.onclose = (event) => { @@ -807,6 +832,12 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet wsRef.current = null; setState('disconnected'); handlersRef.current?.onConnectionChange?.('disconnected'); + // Reject all pending requests + for (const [, pending] of pendingRequestsRef.current) { + clearTimeout(pending.timer); + pending.reject(new Error('Connection lost')); + } + pendingRequestsRef.current.clear(); // Attempt to reconnect if not a clean close if (event.code !== 1000 && shouldReconnectRef.current) { @@ -887,6 +918,36 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet return false; }, []); + /** + * Send a request and wait for a correlated response. + * The server must echo back the requestId in its response. + */ + const sendRequest = useCallback( + (type: string, payload?: Record, timeoutMs: number = 10000): Promise => { + return new Promise((resolve, reject) => { + const requestId = + typeof crypto !== 'undefined' && crypto.randomUUID + ? crypto.randomUUID() + : Date.now().toString(36) + Math.random().toString(36); + + const timer = setTimeout(() => { + pendingRequestsRef.current.delete(requestId); + reject(new Error('Request timed out')); + }, timeoutMs); + + pendingRequestsRef.current.set(requestId, { resolve, reject, timer }); + + const sent = send({ type, ...payload, requestId }); + if (!sent) { + clearTimeout(timer); + pendingRequestsRef.current.delete(requestId); + reject(new Error('WebSocket not connected')); + } + }); + }, + [send] + ); + // Cleanup on unmount - track mount ID to handle StrictMode double-mount useEffect(() => { const thisMountId = ++mountIdRef.current; @@ -928,6 +989,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet authenticate, ping, send, + sendRequest, }; } From c893eac7f5e7231244199d10df1e621f59da3c69 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 17 Mar 2026 18:04:34 +0100 Subject: [PATCH 02/57] MAESTRO: add Auto Run CRUD callbacks to CallbackRegistry Add getAutoRunDocs, getAutoRunDocContent, saveAutoRunDoc, and stopAutoRun callbacks with typed getter/setter methods following existing patterns. Co-Authored-By: Claude Opus 4.6 --- .../web-server/managers/CallbackRegistry.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/main/web-server/managers/CallbackRegistry.ts b/src/main/web-server/managers/CallbackRegistry.ts index 3e10acfd84..ede0c5bb88 100644 --- a/src/main/web-server/managers/CallbackRegistry.ts +++ b/src/main/web-server/managers/CallbackRegistry.ts @@ -28,6 +28,10 @@ import type { GetThemeCallback, GetCustomCommandsCallback, GetHistoryCallback, + GetAutoRunDocsCallback, + GetAutoRunDocContentCallback, + SaveAutoRunDocCallback, + StopAutoRunCallback, } from '../types'; const LOG_CONTEXT = 'CallbackRegistry'; @@ -57,6 +61,10 @@ export interface WebServerCallbacks { refreshAutoRunDocs: RefreshAutoRunDocsCallback | null; configureAutoRun: ConfigureAutoRunCallback | null; getHistory: GetHistoryCallback | null; + getAutoRunDocs: GetAutoRunDocsCallback | null; + getAutoRunDocContent: GetAutoRunDocContentCallback | null; + saveAutoRunDoc: SaveAutoRunDocCallback | null; + stopAutoRun: StopAutoRunCallback | null; } export class CallbackRegistry { @@ -82,6 +90,10 @@ export class CallbackRegistry { refreshAutoRunDocs: null, configureAutoRun: null, getHistory: null, + getAutoRunDocs: null, + getAutoRunDocContent: null, + saveAutoRunDoc: null, + stopAutoRun: null, }; // ============ Getter Methods ============ @@ -198,6 +210,26 @@ export class CallbackRegistry { return this.callbacks.getHistory?.(projectPath, sessionId) ?? []; } + async getAutoRunDocs(sessionId: string): Promise { + if (!this.callbacks.getAutoRunDocs) return []; + return this.callbacks.getAutoRunDocs(sessionId); + } + + async getAutoRunDocContent(sessionId: string, filename: string): Promise { + if (!this.callbacks.getAutoRunDocContent) return ''; + return this.callbacks.getAutoRunDocContent(sessionId, filename); + } + + async saveAutoRunDoc(sessionId: string, filename: string, content: string): Promise { + if (!this.callbacks.saveAutoRunDoc) return false; + return this.callbacks.saveAutoRunDoc(sessionId, filename, content); + } + + async stopAutoRun(sessionId: string): Promise { + if (!this.callbacks.stopAutoRun) return false; + return this.callbacks.stopAutoRun(sessionId); + } + // ============ Setter Methods ============ setGetSessionsCallback(callback: GetSessionsCallback): void { @@ -290,6 +322,22 @@ export class CallbackRegistry { this.callbacks.getHistory = callback; } + setGetAutoRunDocsCallback(callback: GetAutoRunDocsCallback): void { + this.callbacks.getAutoRunDocs = callback; + } + + setGetAutoRunDocContentCallback(callback: GetAutoRunDocContentCallback): void { + this.callbacks.getAutoRunDocContent = callback; + } + + setSaveAutoRunDocCallback(callback: SaveAutoRunDocCallback): void { + this.callbacks.saveAutoRunDoc = callback; + } + + setStopAutoRunCallback(callback: StopAutoRunCallback): void { + this.callbacks.stopAutoRun = callback; + } + // ============ Check Methods ============ hasCallback(name: keyof WebServerCallbacks): boolean { From fdbb69e9e149895b2869562ab4ddf5251bdf6ac3 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 17 Mar 2026 18:08:15 +0100 Subject: [PATCH 03/57] MAESTRO: add Auto Run WS message handlers for web UX parity Add five new WebSocket message handlers to messageHandlers.ts: - get_auto_run_docs: list Auto Run documents for a session - get_auto_run_state: get current Auto Run state via session detail - get_auto_run_document: read content of a specific document (with path validation) - save_auto_run_document: write content to a document (with path validation) - stop_auto_run: stop an active Auto Run session All handlers follow existing patterns: sessionId validation, callback existence checks, async .then()/.catch() error handling, requestId and timestamp in all responses. Filename validation prevents path traversal. Co-Authored-By: Claude Opus 4.6 --- .../web-server/handlers/messageHandlers.ts | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) diff --git a/src/main/web-server/handlers/messageHandlers.ts b/src/main/web-server/handlers/messageHandlers.ts index 2799ea59b6..b53b82a9e4 100644 --- a/src/main/web-server/handlers/messageHandlers.ts +++ b/src/main/web-server/handlers/messageHandlers.ts @@ -20,11 +20,17 @@ * - refresh_file_tree: Refresh the file tree for a session * - refresh_auto_run_docs: Refresh auto-run documents for a session * - configure_auto_run: Configure and optionally launch an auto-run session + * - get_auto_run_docs: List auto-run documents for a session + * - get_auto_run_state: Get current auto-run state for a session + * - get_auto_run_document: Read content of a specific auto-run document + * - save_auto_run_document: Write content to a specific auto-run document + * - stop_auto_run: Stop an active auto-run for a session */ import path from 'path'; import { WebSocket } from 'ws'; import { logger } from '../../utils/logger'; +import type { AutoRunDocument, AutoRunState } from '../types'; // Logger context for all message handler logs const LOG_CONTEXT = 'WebServer'; @@ -118,6 +124,10 @@ export interface MessageHandlerCallbacks { }>; getLiveSessionInfo: (sessionId: string) => LiveSessionInfo | undefined; isSessionLive: (sessionId: string) => boolean; + getAutoRunDocs: (sessionId: string) => Promise; + getAutoRunDocContent: (sessionId: string, filename: string) => Promise; + saveAutoRunDoc: (sessionId: string, filename: string, content: string) => Promise; + stopAutoRun: (sessionId: string) => Promise; } /** @@ -232,6 +242,26 @@ export class WebSocketMessageHandler { this.handleConfigureAutoRun(client, message); break; + case 'get_auto_run_docs': + this.handleGetAutoRunDocs(client, message); + break; + + case 'get_auto_run_state': + this.handleGetAutoRunState(client, message); + break; + + case 'get_auto_run_document': + this.handleGetAutoRunDocument(client, message); + break; + + case 'save_auto_run_document': + this.handleSaveAutoRunDocument(client, message); + break; + + case 'stop_auto_run': + this.handleStopAutoRun(client, message); + break; + default: this.handleUnknown(client, message); } @@ -941,6 +971,202 @@ export class WebSocketMessageHandler { }); } + /** + * Validate that a filename does not contain path traversal sequences. + * Returns true if the filename is safe, false otherwise. + */ + private isValidFilename(filename: string): boolean { + return ( + typeof filename === 'string' && + filename.length > 0 && + !filename.includes('..') && + !filename.includes('/') && + !filename.includes('\\') + ); + } + + /** + * Handle get_auto_run_docs message - list Auto Run documents for a session + */ + private handleGetAutoRunDocs(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string; + logger.info(`[Web] Received get_auto_run_docs message: session=${sessionId}`, LOG_CONTEXT); + + if (!sessionId) { + this.sendError(client, 'Missing sessionId'); + return; + } + + if (!this.callbacks.getAutoRunDocs) { + this.sendError(client, 'Auto-run docs listing not configured'); + return; + } + + this.callbacks + .getAutoRunDocs(sessionId) + .then((documents) => { + this.send(client, { + type: 'auto_run_docs', + sessionId, + documents, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to get auto-run docs: ${error.message}`); + }); + } + + /** + * Handle get_auto_run_state message - get current Auto Run state for a session + */ + private handleGetAutoRunState(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string; + logger.info(`[Web] Received get_auto_run_state message: session=${sessionId}`, LOG_CONTEXT); + + if (!sessionId) { + this.sendError(client, 'Missing sessionId'); + return; + } + + if (!this.callbacks.getSessionDetail) { + this.sendError(client, 'Session detail not configured'); + return; + } + + const detail = this.callbacks.getSessionDetail(sessionId); + const state: AutoRunState | null = (detail as Record)?.autoRunState as AutoRunState | null ?? null; + + this.send(client, { + type: 'auto_run_state', + sessionId, + state, + requestId: message.requestId, + }); + } + + /** + * Handle get_auto_run_document message - read content of a specific Auto Run document + */ + private handleGetAutoRunDocument(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string; + const filename = message.filename as string; + logger.info( + `[Web] Received get_auto_run_document message: session=${sessionId}, filename=${filename}`, + LOG_CONTEXT + ); + + if (!sessionId || !filename) { + this.sendError(client, 'Missing sessionId or filename'); + return; + } + + if (!this.isValidFilename(filename)) { + this.sendError(client, 'Invalid filename: must not contain path separators or traversal sequences'); + return; + } + + if (!this.callbacks.getAutoRunDocContent) { + this.sendError(client, 'Auto-run document reading not configured'); + return; + } + + this.callbacks + .getAutoRunDocContent(sessionId, filename) + .then((content) => { + this.send(client, { + type: 'auto_run_document_content', + sessionId, + filename, + content, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to read auto-run document: ${error.message}`); + }); + } + + /** + * Handle save_auto_run_document message - write content to a specific Auto Run document + */ + private handleSaveAutoRunDocument(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string; + const filename = message.filename as string; + const content = message.content as string; + logger.info( + `[Web] Received save_auto_run_document message: session=${sessionId}, filename=${filename}`, + LOG_CONTEXT + ); + + if (!sessionId || !filename) { + this.sendError(client, 'Missing sessionId or filename'); + return; + } + + if (typeof content !== 'string') { + this.sendError(client, 'Missing or invalid content'); + return; + } + + if (!this.isValidFilename(filename)) { + this.sendError(client, 'Invalid filename: must not contain path separators or traversal sequences'); + return; + } + + if (!this.callbacks.saveAutoRunDoc) { + this.sendError(client, 'Auto-run document saving not configured'); + return; + } + + this.callbacks + .saveAutoRunDoc(sessionId, filename, content) + .then((success) => { + this.send(client, { + type: 'save_auto_run_document_result', + success, + sessionId, + filename, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to save auto-run document: ${error.message}`); + }); + } + + /** + * Handle stop_auto_run message - stop an active Auto Run for a session + */ + private handleStopAutoRun(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string; + logger.info(`[Web] Received stop_auto_run message: session=${sessionId}`, LOG_CONTEXT); + + if (!sessionId) { + this.sendError(client, 'Missing sessionId'); + return; + } + + if (!this.callbacks.stopAutoRun) { + this.sendError(client, 'Auto-run stopping not configured'); + return; + } + + this.callbacks + .stopAutoRun(sessionId) + .then((success) => { + this.send(client, { + type: 'stop_auto_run_result', + success, + sessionId, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to stop auto-run: ${error.message}`); + }); + } + /** * Handle unknown message types - echo back for debugging */ From 52134fd0247400364e448b34d11b648feaaf6e3e Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 17 Mar 2026 18:11:22 +0100 Subject: [PATCH 04/57] MAESTRO: wire Auto Run CRUD callbacks in web-server-factory with IPC request-response Wire four new Auto Run callbacks in web-server-factory.ts: - getAutoRunDocs: IPC request-response with 10s timeout, returns document list - getAutoRunDocContent: IPC request-response with 10s timeout, returns file content - saveAutoRunDoc: IPC request-response with 10s timeout, returns success boolean - stopAutoRun: fire-and-forget IPC pattern (like interrupt) Also adds corresponding setter delegation methods in WebServer.ts and fixes a pre-existing TS2352 type error in messageHandlers.ts. Co-Authored-By: Claude Opus 4.6 --- src/main/web-server/WebServer.ts | 20 +++ .../web-server/handlers/messageHandlers.ts | 2 +- src/main/web-server/web-server-factory.ts | 134 ++++++++++++++++++ 3 files changed, 155 insertions(+), 1 deletion(-) diff --git a/src/main/web-server/WebServer.ts b/src/main/web-server/WebServer.ts index ae2769cd5d..f56e77620d 100644 --- a/src/main/web-server/WebServer.ts +++ b/src/main/web-server/WebServer.ts @@ -69,6 +69,10 @@ import type { GetThemeCallback, GetCustomCommandsCallback, GetHistoryCallback, + GetAutoRunDocsCallback, + GetAutoRunDocContentCallback, + SaveAutoRunDocCallback, + StopAutoRunCallback, } from './types'; // Logger context for all web server logs @@ -325,6 +329,22 @@ export class WebServer { this.callbackRegistry.setGetHistoryCallback(callback); } + setGetAutoRunDocsCallback(callback: GetAutoRunDocsCallback): void { + this.callbackRegistry.setGetAutoRunDocsCallback(callback); + } + + setGetAutoRunDocContentCallback(callback: GetAutoRunDocContentCallback): void { + this.callbackRegistry.setGetAutoRunDocContentCallback(callback); + } + + setSaveAutoRunDocCallback(callback: SaveAutoRunDocCallback): void { + this.callbackRegistry.setSaveAutoRunDocCallback(callback); + } + + setStopAutoRunCallback(callback: StopAutoRunCallback): void { + this.callbackRegistry.setStopAutoRunCallback(callback); + } + // ============ Rate Limiting ============ setRateLimitConfig(config: Partial): void { diff --git a/src/main/web-server/handlers/messageHandlers.ts b/src/main/web-server/handlers/messageHandlers.ts index b53b82a9e4..ca8d3e4713 100644 --- a/src/main/web-server/handlers/messageHandlers.ts +++ b/src/main/web-server/handlers/messageHandlers.ts @@ -1035,7 +1035,7 @@ export class WebSocketMessageHandler { } const detail = this.callbacks.getSessionDetail(sessionId); - const state: AutoRunState | null = (detail as Record)?.autoRunState as AutoRunState | null ?? null; + const state: AutoRunState | null = (detail as any)?.autoRunState as AutoRunState | null ?? null; this.send(client, { type: 'auto_run_state', diff --git a/src/main/web-server/web-server-factory.ts b/src/main/web-server/web-server-factory.ts index 86b0a854d6..2969cfa3ff 100644 --- a/src/main/web-server/web-server-factory.ts +++ b/src/main/web-server/web-server-factory.ts @@ -575,6 +575,140 @@ export function createWebServerFactory(deps: WebServerFactoryDependencies) { }); }); + // Set up callback for web server to fetch Auto Run documents list + // Uses IPC request-response pattern with timeout + server.setGetAutoRunDocsCallback(async (sessionId: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for getAutoRunDocs', 'WebServer'); + return []; + } + + return new Promise((resolve) => { + const responseChannel = `remote:getAutoRunDocs:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result || []); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for getAutoRunDocs', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve([]); + return; + } + mainWindow.webContents.send('remote:getAutoRunDocs', sessionId, responseChannel); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn(`getAutoRunDocs callback timed out for session ${sessionId}`, 'WebServer'); + resolve([]); + }, 10000); + }); + }); + + // Set up callback for web server to fetch Auto Run document content + // Uses IPC request-response pattern with timeout + server.setGetAutoRunDocContentCallback(async (sessionId: string, filename: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for getAutoRunDocContent', 'WebServer'); + return ''; + } + + return new Promise((resolve) => { + const responseChannel = `remote:getAutoRunDocContent:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result ?? ''); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for getAutoRunDocContent', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve(''); + return; + } + mainWindow.webContents.send('remote:getAutoRunDocContent', sessionId, filename, responseChannel); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn(`getAutoRunDocContent callback timed out for session ${sessionId}`, 'WebServer'); + resolve(''); + }, 10000); + }); + }); + + // Set up callback for web server to save Auto Run document content + // Uses IPC request-response pattern with timeout + server.setSaveAutoRunDocCallback(async (sessionId: string, filename: string, content: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for saveAutoRunDoc', 'WebServer'); + return false; + } + + return new Promise((resolve) => { + const responseChannel = `remote:saveAutoRunDoc:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result ?? false); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for saveAutoRunDoc', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve(false); + return; + } + mainWindow.webContents.send('remote:saveAutoRunDoc', sessionId, filename, content, responseChannel); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn(`saveAutoRunDoc callback timed out for session ${sessionId}`, 'WebServer'); + resolve(false); + }, 10000); + }); + }); + + // Set up callback for web server to stop Auto Run + // Fire-and-forget pattern (like interrupt) + server.setStopAutoRunCallback(async (sessionId: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for stopAutoRun', 'WebServer'); + return false; + } + + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for stopAutoRun', 'WebServer'); + return false; + } + mainWindow.webContents.send('remote:stopAutoRun', sessionId); + return true; + }); + return server; }; } From 97692cc72b739679d5fc2840ede7a45c4eb355ba Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 17 Mar 2026 18:20:15 +0100 Subject: [PATCH 05/57] MAESTRO: add renderer-side IPC handlers for Auto Run web operations Wire up four new remote IPC handlers that bridge the web server to the renderer's existing Auto Run infrastructure: - remote:getAutoRunDocs - lists docs via session's autoRunFolderPath - remote:getAutoRunDocContent - reads doc content with SSH support - remote:saveAutoRunDoc - writes doc content with SSH support - remote:stopAutoRun - stops batch run without confirmation dialog Changes span preload API (process.ts), type declarations (global.d.ts), event dispatching (useRemoteIntegration.ts), and business logic (App.tsx). Also exports stopBatchRun from useBatchHandlers for direct web access. Co-Authored-By: Claude Opus 4.6 --- src/main/preload/process.ts | 105 ++++++++++++++++++ src/renderer/App.tsx | 103 +++++++++++++++++ src/renderer/global.d.ts | 18 +++ src/renderer/hooks/batch/useBatchHandlers.ts | 3 + .../hooks/remote/useRemoteIntegration.ts | 62 +++++++++++ 5 files changed, 291 insertions(+) diff --git a/src/main/preload/process.ts b/src/main/preload/process.ts index 246bc68c37..775569c09c 100644 --- a/src/main/preload/process.ts +++ b/src/main/preload/process.ts @@ -501,6 +501,111 @@ export function createProcessApi() { ipcRenderer.send(responseChannel, result); }, + /** + * Subscribe to remote get auto-run docs from web interface (request-response) + */ + onRemoteGetAutoRunDocs: ( + callback: (sessionId: string, responseChannel: string) => void + ): (() => void) => { + const handler = (_: unknown, sessionId: string, responseChannel: string) => { + try { + Promise.resolve(callback(sessionId, responseChannel)).catch(() => { + ipcRenderer.send(responseChannel, []); + }); + } catch { + ipcRenderer.send(responseChannel, []); + } + }; + ipcRenderer.on('remote:getAutoRunDocs', handler); + return () => ipcRenderer.removeListener('remote:getAutoRunDocs', handler); + }, + + /** + * Send response for remote get auto-run docs + */ + sendRemoteGetAutoRunDocsResponse: (responseChannel: string, documents: any[]): void => { + ipcRenderer.send(responseChannel, documents); + }, + + /** + * Subscribe to remote get auto-run doc content from web interface (request-response) + */ + onRemoteGetAutoRunDocContent: ( + callback: (sessionId: string, filename: string, responseChannel: string) => void + ): (() => void) => { + const handler = ( + _: unknown, + sessionId: string, + filename: string, + responseChannel: string + ) => { + try { + Promise.resolve(callback(sessionId, filename, responseChannel)).catch(() => { + ipcRenderer.send(responseChannel, ''); + }); + } catch { + ipcRenderer.send(responseChannel, ''); + } + }; + ipcRenderer.on('remote:getAutoRunDocContent', handler); + return () => ipcRenderer.removeListener('remote:getAutoRunDocContent', handler); + }, + + /** + * Send response for remote get auto-run doc content + */ + sendRemoteGetAutoRunDocContentResponse: (responseChannel: string, content: string): void => { + ipcRenderer.send(responseChannel, content); + }, + + /** + * Subscribe to remote save auto-run doc from web interface (request-response) + */ + onRemoteSaveAutoRunDoc: ( + callback: ( + sessionId: string, + filename: string, + content: string, + responseChannel: string + ) => void + ): (() => void) => { + const handler = ( + _: unknown, + sessionId: string, + filename: string, + content: string, + responseChannel: string + ) => { + try { + Promise.resolve(callback(sessionId, filename, content, responseChannel)).catch( + () => { + ipcRenderer.send(responseChannel, false); + } + ); + } catch { + ipcRenderer.send(responseChannel, false); + } + }; + ipcRenderer.on('remote:saveAutoRunDoc', handler); + return () => ipcRenderer.removeListener('remote:saveAutoRunDoc', handler); + }, + + /** + * Send response for remote save auto-run doc + */ + sendRemoteSaveAutoRunDocResponse: (responseChannel: string, success: boolean): void => { + ipcRenderer.send(responseChannel, success); + }, + + /** + * Subscribe to remote stop auto-run from web interface (fire-and-forget) + */ + onRemoteStopAutoRun: (callback: (sessionId: string) => void): (() => void) => { + const handler = (_: unknown, sessionId: string) => callback(sessionId); + ipcRenderer.on('remote:stopAutoRun', handler); + return () => ipcRenderer.removeListener('remote:stopAutoRun', handler); + }, + /** * Subscribe to stderr from runCommand (separate stream) */ diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 8f9632bb41..2b603b128a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1254,6 +1254,7 @@ function MaestroConsoleInner() { // --- BATCH HANDLERS (Auto Run processing, quit confirmation, error handling) --- const { startBatchRun, + stopBatchRun, getBatchState, handleStopBatchRun, handleKillBatchRun, @@ -1925,6 +1926,108 @@ function MaestroConsoleInner() { return () => window.removeEventListener('maestro:configureAutoRun', handler); }, [sessionsRef, startBatchRun]); + // Handle remote get auto-run docs from web interface + useEffect(() => { + const handler = async (e: Event) => { + const { sessionId, responseChannel } = (e as CustomEvent).detail; + try { + const session = sessionsRef.current.find((s) => s.id === sessionId); + if (!session?.autoRunFolderPath) { + window.maestro.process.sendRemoteGetAutoRunDocsResponse(responseChannel, []); + return; + } + const sshRemoteId = + session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined; + const listResult = await window.maestro.autorun.listDocs( + session.autoRunFolderPath, + sshRemoteId + ); + const files = listResult.success ? listResult.files || [] : []; + window.maestro.process.sendRemoteGetAutoRunDocsResponse(responseChannel, files); + } catch (error) { + console.error('[Remote] Failed to get auto-run docs:', error); + window.maestro.process.sendRemoteGetAutoRunDocsResponse(responseChannel, []); + } + }; + window.addEventListener('maestro:getAutoRunDocs', handler); + return () => window.removeEventListener('maestro:getAutoRunDocs', handler); + }, [sessionsRef]); + + // Handle remote get auto-run doc content from web interface + useEffect(() => { + const handler = async (e: Event) => { + const { sessionId, filename, responseChannel } = (e as CustomEvent).detail; + try { + const session = sessionsRef.current.find((s) => s.id === sessionId); + if (!session?.autoRunFolderPath) { + window.maestro.process.sendRemoteGetAutoRunDocContentResponse( + responseChannel, + '' + ); + return; + } + const sshRemoteId = + session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined; + const contentResult = await window.maestro.autorun.readDoc( + session.autoRunFolderPath, + filename, + sshRemoteId + ); + const content = contentResult.success ? contentResult.content || '' : ''; + window.maestro.process.sendRemoteGetAutoRunDocContentResponse( + responseChannel, + content + ); + } catch (error) { + console.error('[Remote] Failed to get auto-run doc content:', error); + window.maestro.process.sendRemoteGetAutoRunDocContentResponse(responseChannel, ''); + } + }; + window.addEventListener('maestro:getAutoRunDocContent', handler); + return () => window.removeEventListener('maestro:getAutoRunDocContent', handler); + }, [sessionsRef]); + + // Handle remote save auto-run doc from web interface + useEffect(() => { + const handler = async (e: Event) => { + const { sessionId, filename, content, responseChannel } = (e as CustomEvent).detail; + try { + const session = sessionsRef.current.find((s) => s.id === sessionId); + if (!session?.autoRunFolderPath) { + window.maestro.process.sendRemoteSaveAutoRunDocResponse(responseChannel, false); + return; + } + const sshRemoteId = + session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined; + const writeResult = await window.maestro.autorun.writeDoc( + session.autoRunFolderPath, + filename, + content, + sshRemoteId + ); + window.maestro.process.sendRemoteSaveAutoRunDocResponse( + responseChannel, + writeResult.success ?? false + ); + } catch (error) { + console.error('[Remote] Failed to save auto-run doc:', error); + window.maestro.process.sendRemoteSaveAutoRunDocResponse(responseChannel, false); + } + }; + window.addEventListener('maestro:saveAutoRunDoc', handler); + return () => window.removeEventListener('maestro:saveAutoRunDoc', handler); + }, [sessionsRef]); + + // Handle remote stop auto-run from web interface (fire-and-forget, no confirmation dialog) + useEffect(() => { + const handler = (e: Event) => { + const { sessionId } = (e as CustomEvent).detail; + stopBatchRun(sessionId); + }; + window.addEventListener('maestro:stopAutoRun', handler); + return () => window.removeEventListener('maestro:stopAutoRun', handler); + }, [stopBatchRun]); + // --- GROUP MANAGEMENT --- // Extracted hook for group CRUD operations (toggle, rename, create, drag-drop) const { diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index fdc28c28c4..0c14721fb8 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -355,6 +355,24 @@ interface MaestroAPI { responseChannel: string, result: { success: boolean; playbookId?: string; error?: string } ) => void; + onRemoteGetAutoRunDocs: ( + callback: (sessionId: string, responseChannel: string) => void + ) => () => void; + sendRemoteGetAutoRunDocsResponse: (responseChannel: string, documents: any[]) => void; + onRemoteGetAutoRunDocContent: ( + callback: (sessionId: string, filename: string, responseChannel: string) => void + ) => () => void; + sendRemoteGetAutoRunDocContentResponse: (responseChannel: string, content: string) => void; + onRemoteSaveAutoRunDoc: ( + callback: ( + sessionId: string, + filename: string, + content: string, + responseChannel: string + ) => void + ) => () => void; + sendRemoteSaveAutoRunDocResponse: (responseChannel: string, success: boolean) => void; + onRemoteStopAutoRun: (callback: (sessionId: string) => void) => () => void; onStderr: (callback: (sessionId: string, data: string) => void) => () => void; onCommandExit: (callback: (sessionId: string, code: number) => void) => () => void; onUsage: (callback: (sessionId: string, usageStats: UsageStats) => void) => () => void; diff --git a/src/renderer/hooks/batch/useBatchHandlers.ts b/src/renderer/hooks/batch/useBatchHandlers.ts index befc5bf7e4..80b4bb83db 100644 --- a/src/renderer/hooks/batch/useBatchHandlers.ts +++ b/src/renderer/hooks/batch/useBatchHandlers.ts @@ -61,6 +61,8 @@ export interface UseBatchHandlersDeps { export interface UseBatchHandlersReturn { /** Start a batch run for a session */ startBatchRun: (sessionId: string, config: BatchRunConfig, folderPath: string) => Promise; + /** Stop a batch run directly (no confirmation dialog, used by web remote) */ + stopBatchRun: (sessionId: string) => void; /** Get batch state for a specific session */ getBatchState: (sessionId: string) => BatchRunState; /** Stop batch run with confirmation dialog */ @@ -703,6 +705,7 @@ export function useBatchHandlers(deps: UseBatchHandlersDeps): UseBatchHandlersRe return { startBatchRun, + stopBatchRun, getBatchState, handleStopBatchRun, handleKillBatchRun, diff --git a/src/renderer/hooks/remote/useRemoteIntegration.ts b/src/renderer/hooks/remote/useRemoteIntegration.ts index eaf5e757ab..51a44f80b6 100644 --- a/src/renderer/hooks/remote/useRemoteIntegration.ts +++ b/src/renderer/hooks/remote/useRemoteIntegration.ts @@ -496,6 +496,68 @@ export function useRemoteIntegration(deps: UseRemoteIntegrationDeps): UseRemoteI }; }, []); + // Handle remote get auto-run docs from web interface + useEffect(() => { + const unsubscribe = window.maestro.process.onRemoteGetAutoRunDocs( + (sessionId: string, responseChannel: string) => { + window.dispatchEvent( + new CustomEvent('maestro:getAutoRunDocs', { + detail: { sessionId, responseChannel }, + }) + ); + } + ); + return () => { + unsubscribe(); + }; + }, []); + + // Handle remote get auto-run doc content from web interface + useEffect(() => { + const unsubscribe = window.maestro.process.onRemoteGetAutoRunDocContent( + (sessionId: string, filename: string, responseChannel: string) => { + window.dispatchEvent( + new CustomEvent('maestro:getAutoRunDocContent', { + detail: { sessionId, filename, responseChannel }, + }) + ); + } + ); + return () => { + unsubscribe(); + }; + }, []); + + // Handle remote save auto-run doc from web interface + useEffect(() => { + const unsubscribe = window.maestro.process.onRemoteSaveAutoRunDoc( + (sessionId: string, filename: string, content: string, responseChannel: string) => { + window.dispatchEvent( + new CustomEvent('maestro:saveAutoRunDoc', { + detail: { sessionId, filename, content, responseChannel }, + }) + ); + } + ); + return () => { + unsubscribe(); + }; + }, []); + + // Handle remote stop auto-run from web interface + useEffect(() => { + const unsubscribe = window.maestro.process.onRemoteStopAutoRun((sessionId: string) => { + window.dispatchEvent( + new CustomEvent('maestro:stopAutoRun', { + detail: { sessionId }, + }) + ); + }); + return () => { + unsubscribe(); + }; + }, []); + // Broadcast tab changes to web clients when tabs, activeTabId, or tab properties change // PERFORMANCE FIX: This effect was previously missing its dependency array, causing it to // run on EVERY render (including every keystroke). Now it only runs when isLiveMode changes, From f4db8e1b2ffbdb2685a16bcc1bb74bf0018a09c4 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 17 Mar 2026 18:23:25 +0100 Subject: [PATCH 06/57] MAESTRO: add autorun_docs_changed broadcast for web UX parity Co-Authored-By: Claude Opus 4.6 --- src/main/web-server/WebServer.ts | 5 +++++ src/main/web-server/services/broadcastService.ts | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/main/web-server/WebServer.ts b/src/main/web-server/WebServer.ts index f56e77620d..3afb2ebd12 100644 --- a/src/main/web-server/WebServer.ts +++ b/src/main/web-server/WebServer.ts @@ -44,6 +44,7 @@ import type { AITabData, CustomAICommand, AutoRunState, + AutoRunDocument, CliActivity, SessionBroadcastData, WebClient, @@ -562,6 +563,10 @@ export class WebServer { this.liveSessionManager.setAutoRunState(sessionId, state); } + broadcastAutoRunDocsChanged(sessionId: string, documents: AutoRunDocument[]): void { + this.broadcastService.broadcastAutoRunDocsChanged(sessionId, documents); + } + broadcastUserInput(sessionId: string, command: string, inputMode: 'ai' | 'terminal'): void { this.broadcastService.broadcastUserInput(sessionId, command, inputMode); } diff --git a/src/main/web-server/services/broadcastService.ts b/src/main/web-server/services/broadcastService.ts index d66c9eaae6..87242e2722 100644 --- a/src/main/web-server/services/broadcastService.ts +++ b/src/main/web-server/services/broadcastService.ts @@ -15,6 +15,7 @@ * - theme: Theme updates * - custom_commands: Custom AI commands updates * - autorun_state: Auto Run batch processing state + * - autorun_docs_changed: Auto Run document list changes * - user_input: User input from desktop (for web client sync) * - session_output: Session output data */ @@ -28,6 +29,7 @@ import type { AITabData, SessionBroadcastData, AutoRunState, + AutoRunDocument, CliActivity, } from '../types'; @@ -225,6 +227,19 @@ export class BroadcastService { }); } + /** + * Broadcast Auto Run documents changed to all connected web clients + * Called when Auto Run documents are added, removed, or modified + */ + broadcastAutoRunDocsChanged(sessionId: string, documents: AutoRunDocument[]): void { + this.broadcastToAll({ + type: 'autorun_docs_changed', + sessionId, + documents, + timestamp: Date.now(), + }); + } + /** * Broadcast user input to web clients subscribed to a session * Called when a command is sent from the desktop app so web clients stay in sync From 7ba91f85f7ec8e7b78b3191f5bb734c87d709ad2 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 17 Mar 2026 18:27:09 +0100 Subject: [PATCH 07/57] MAESTRO: add useAutoRun hook and autorun_docs_changed WebSocket handler for web UX parity Co-Authored-By: Claude Opus 4.6 --- src/web/hooks/useAutoRun.ts | 167 ++++++++++++++++++++++++++++++++++ src/web/hooks/useWebSocket.ts | 25 +++++ 2 files changed, 192 insertions(+) create mode 100644 src/web/hooks/useAutoRun.ts diff --git a/src/web/hooks/useAutoRun.ts b/src/web/hooks/useAutoRun.ts new file mode 100644 index 0000000000..3e22540c9b --- /dev/null +++ b/src/web/hooks/useAutoRun.ts @@ -0,0 +1,167 @@ +/** + * useAutoRun hook for Auto Run state management in the web interface. + * + * Provides document listing, content loading/saving, launch/stop controls, + * and real-time document change tracking via WebSocket broadcasts. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { UseWebSocketReturn, AutoRunState } from './useWebSocket'; + +/** + * Auto Run document metadata (mirrors server-side AutoRunDocument). + */ +export interface AutoRunDocument { + filename: string; + path: string; + taskCount: number; + completedCount: number; +} + +/** + * Currently selected document with content. + */ +export interface SelectedDocument { + filename: string; + content: string; +} + +/** + * Launch configuration for Auto Run. + */ +export interface LaunchConfig { + documents: Array<{ filename: string }>; + prompt?: string; + loopEnabled?: boolean; + maxLoops?: number; +} + +/** + * Return value from useAutoRun hook. + */ +export interface UseAutoRunReturn { + documents: AutoRunDocument[]; + autoRunState: AutoRunState | null; + isLoadingDocs: boolean; + selectedDoc: SelectedDocument | null; + loadDocuments: (sessionId: string) => Promise; + loadDocumentContent: (sessionId: string, filename: string) => Promise; + saveDocumentContent: (sessionId: string, filename: string, content: string) => Promise; + launchAutoRun: (sessionId: string, config: LaunchConfig) => boolean; + stopAutoRun: (sessionId: string) => Promise; +} + +/** + * Hook for managing Auto Run state and operations. + * + * @param sendRequest - WebSocket sendRequest function for request-response operations + * @param send - WebSocket send function for fire-and-forget messages + * @param onMessage - Optional message handler registration callback + */ +export function useAutoRun( + sendRequest: UseWebSocketReturn['sendRequest'], + send: UseWebSocketReturn['send'], + autoRunState: AutoRunState | null = null, +): UseAutoRunReturn { + const [documents, setDocuments] = useState([]); + const [isLoadingDocs, setIsLoadingDocs] = useState(false); + const [selectedDoc, setSelectedDoc] = useState(null); + + // Track the current sessionId for auto-refresh + const currentSessionIdRef = useRef(null); + + const loadDocuments = useCallback( + async (sessionId: string) => { + setIsLoadingDocs(true); + try { + const response = await sendRequest<{ documents?: AutoRunDocument[] }>( + 'get_auto_run_docs', + { sessionId }, + ); + setDocuments(response.documents ?? []); + } catch { + setDocuments([]); + } finally { + setIsLoadingDocs(false); + } + }, + [sendRequest], + ); + + const loadDocumentContent = useCallback( + async (sessionId: string, filename: string) => { + try { + const response = await sendRequest<{ content?: string }>( + 'get_auto_run_document', + { sessionId, filename }, + ); + setSelectedDoc({ + filename, + content: response.content ?? '', + }); + } catch { + setSelectedDoc({ filename, content: '' }); + } + }, + [sendRequest], + ); + + const saveDocumentContent = useCallback( + async (sessionId: string, filename: string, content: string): Promise => { + try { + const response = await sendRequest<{ success?: boolean }>( + 'save_auto_run_document', + { sessionId, filename, content }, + ); + return response.success ?? false; + } catch { + return false; + } + }, + [sendRequest], + ); + + const launchAutoRun = useCallback( + (sessionId: string, config: LaunchConfig): boolean => { + return send({ + type: 'configure_auto_run', + sessionId, + documents: config.documents, + prompt: config.prompt, + loopEnabled: config.loopEnabled, + maxLoops: config.maxLoops, + launch: true, + }); + }, + [send], + ); + + const stopAutoRun = useCallback( + async (sessionId: string): Promise => { + try { + const response = await sendRequest<{ success?: boolean }>( + 'stop_auto_run', + { sessionId }, + ); + return response.success ?? false; + } catch { + return false; + } + }, + [sendRequest], + ); + + return { + documents, + autoRunState, + isLoadingDocs, + selectedDoc, + loadDocuments, + loadDocumentContent, + saveDocumentContent, + launchAutoRun, + stopAutoRun, + }; +} + +export default useAutoRun; diff --git a/src/web/hooks/useWebSocket.ts b/src/web/hooks/useWebSocket.ts index 34ea3571b9..f0b9625c0d 100644 --- a/src/web/hooks/useWebSocket.ts +++ b/src/web/hooks/useWebSocket.ts @@ -122,6 +122,7 @@ export type ServerMessageType = | 'theme' | 'custom_commands' | 'autorun_state' + | 'autorun_docs_changed' | 'tabs_changed' | 'pong' | 'subscribed' @@ -286,6 +287,21 @@ export interface AutoRunStateMessage extends ServerMessage { state: AutoRunState | null; } +/** + * AutoRun documents changed message from server + * Sent when Auto Run document list changes on the desktop + */ +export interface AutoRunDocsChangedMessage extends ServerMessage { + type: 'autorun_docs_changed'; + sessionId: string; + documents: Array<{ + filename: string; + path: string; + taskCount: number; + completedCount: number; + }>; +} + /** * Tabs changed message from server * Sent when tabs are added, removed, or active tab changes in a session @@ -324,6 +340,7 @@ export type TypedServerMessage = | ThemeMessage | CustomCommandsMessage | AutoRunStateMessage + | AutoRunDocsChangedMessage | TabsChangedMessage | ErrorMessage | ServerMessage; @@ -363,6 +380,8 @@ export interface WebSocketEventHandlers { onCustomCommands?: (commands: CustomCommand[]) => void; /** Called when AutoRun state changes (batch processing on desktop) */ onAutoRunStateChange?: (sessionId: string, state: AutoRunState | null) => void; + /** Called when AutoRun document list changes */ + onAutoRunDocsChanged?: (sessionId: string, documents: AutoRunDocsChangedMessage['documents']) => void; /** Called when tabs change in a session */ onTabsChanged?: (sessionId: string, aiTabs: AITabData[], activeTabId: string) => void; /** Called when connection state changes */ @@ -716,6 +735,12 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet break; } + case 'autorun_docs_changed': { + const docsMsg = message as AutoRunDocsChangedMessage; + handlersRef.current?.onAutoRunDocsChanged?.(docsMsg.sessionId, docsMsg.documents); + break; + } + case 'tabs_changed': { const tabsMsg = message as TabsChangedMessage; handlersRef.current?.onTabsChanged?.( From e22381fef8a48b43440cd6e009cd20f55ce22372 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 17 Mar 2026 18:32:42 +0100 Subject: [PATCH 08/57] MAESTRO: add AutoRunPanel full-screen management view for mobile web Full-screen panel for Auto Run document management with: - Header with refresh and close buttons - Status bar with progress when running (reuses AutoRunIndicator visual style) - Configure & Launch / Stop controls - Scrollable document card list with progress indicators - Empty state with .maestro/auto-run/ directory hint - Safe area insets, 44px touch targets, haptic feedback Co-Authored-By: Claude Opus 4.6 --- src/web/mobile/AutoRunPanel.tsx | 541 ++++++++++++++++++++++++++++++++ 1 file changed, 541 insertions(+) create mode 100644 src/web/mobile/AutoRunPanel.tsx diff --git a/src/web/mobile/AutoRunPanel.tsx b/src/web/mobile/AutoRunPanel.tsx new file mode 100644 index 0000000000..5951e966ec --- /dev/null +++ b/src/web/mobile/AutoRunPanel.tsx @@ -0,0 +1,541 @@ +/** + * AutoRunPanel component for Maestro mobile web interface + * + * Full-screen management panel for Auto Run documents. + * Provides document listing, launch/stop controls, and navigation + * to document viewer and setup sheet. + */ + +import { useState, useCallback, useEffect } from 'react'; +import { useThemeColors } from '../components/ThemeProvider'; +import { useAutoRun, type AutoRunDocument } from '../hooks/useAutoRun'; +import type { AutoRunState, UseWebSocketReturn } from '../hooks/useWebSocket'; +import { triggerHaptic, HAPTIC_PATTERNS } from './constants'; + +/** + * Document card component for the Auto Run panel + */ +interface DocumentCardProps { + document: AutoRunDocument; + onTap: (filename: string) => void; +} + +function DocumentCard({ document, onTap }: DocumentCardProps) { + const colors = useThemeColors(); + const progress = document.taskCount > 0 + ? Math.round((document.completedCount / document.taskCount) * 100) + : 0; + + const handleTap = useCallback(() => { + triggerHaptic(HAPTIC_PATTERNS.tap); + onTap(document.filename); + }, [document.filename, onTap]); + + return ( + + ); +} + +/** + * Props for AutoRunPanel component + */ +export interface AutoRunPanelProps { + sessionId: string; + autoRunState: AutoRunState | null; + onClose: () => void; + onOpenDocument?: (filename: string) => void; + onOpenSetup?: () => void; + sendRequest: UseWebSocketReturn['sendRequest']; + send: UseWebSocketReturn['send']; +} + +/** + * AutoRunPanel component + * + * Full-screen panel for managing Auto Run documents, launching/stopping runs, + * and navigating to document viewer and setup sheet. + */ +export function AutoRunPanel({ + sessionId, + autoRunState, + onClose, + onOpenDocument, + onOpenSetup, + sendRequest, + send, +}: AutoRunPanelProps) { + const colors = useThemeColors(); + const [isStopping, setIsStopping] = useState(false); + + const { + documents, + isLoadingDocs, + loadDocuments, + stopAutoRun, + } = useAutoRun(sendRequest, send, autoRunState); + + // Load documents on mount and when sessionId changes + useEffect(() => { + loadDocuments(sessionId); + }, [sessionId, loadDocuments]); + + // Reset stopping state when autoRun stops + useEffect(() => { + if (!autoRunState?.isRunning) { + setIsStopping(false); + } + }, [autoRunState?.isRunning]); + + const handleClose = useCallback(() => { + triggerHaptic(HAPTIC_PATTERNS.tap); + onClose(); + }, [onClose]); + + const handleRefresh = useCallback(() => { + triggerHaptic(HAPTIC_PATTERNS.tap); + loadDocuments(sessionId); + }, [sessionId, loadDocuments]); + + const handleStop = useCallback(async () => { + triggerHaptic(HAPTIC_PATTERNS.interrupt); + setIsStopping(true); + const success = await stopAutoRun(sessionId); + if (!success) { + setIsStopping(false); + } + }, [sessionId, stopAutoRun]); + + const handleConfigure = useCallback(() => { + triggerHaptic(HAPTIC_PATTERNS.tap); + onOpenSetup?.(); + }, [onOpenSetup]); + + const handleDocumentTap = useCallback((filename: string) => { + onOpenDocument?.(filename); + }, [onOpenDocument]); + + // Close on escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const isRunning = autoRunState?.isRunning ?? false; + const totalTasks = autoRunState?.totalTasks ?? 0; + const completedTasks = autoRunState?.completedTasks ?? 0; + const currentTaskIndex = autoRunState?.currentTaskIndex ?? 0; + const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; + const totalDocs = autoRunState?.totalDocuments; + const currentDocIndex = autoRunState?.currentDocumentIndex; + + return ( +
+ {/* Header */} +
+

+ Auto Run +

+ +
+ {/* Refresh button */} + + + {/* Close button */} + +
+
+ + {/* Status bar (when running) */} + {isRunning && ( +
+ {/* Progress badge */} +
+ {progress}% +
+ + {/* Status text */} +
+
+ Task {currentTaskIndex + 1}/{totalTasks} + {totalDocs != null && currentDocIndex != null && totalDocs > 1 && ( + Doc {currentDocIndex + 1}/{totalDocs} + )} +
+ + {/* Progress bar */} +
+
+
+
+
+ )} + + {/* Controls bar */} +
+ {/* Configure & Launch button */} + + + {/* Stop button (visible only when running) */} + {isRunning && ( + + )} +
+ + {/* Document list */} +
+ {isLoadingDocs ? ( +
+ Loading documents... +
+ ) : documents.length === 0 ? ( + /* Empty state */ +
+

+ No Auto Run documents found +

+

+ Create documents in the .maestro/auto-run/ directory to get started +

+
+ ) : ( +
+ {documents.map((doc) => ( + + ))} +
+ )} +
+ + {/* Animation keyframes */} + +
+ ); +} + +export default AutoRunPanel; From 842208a586a3589844cca89e82d9abdfd03e5413 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 17 Mar 2026 18:40:43 +0100 Subject: [PATCH 09/57] MAESTRO: add AutoRunDocumentViewer full-screen markdown viewer/editor for mobile web Co-Authored-By: Claude Opus 4.6 --- src/web/hooks/useAutoRun.ts | 5 +- src/web/mobile/AutoRunDocumentViewer.tsx | 507 +++++++++++++++++++++++ 2 files changed, 508 insertions(+), 4 deletions(-) create mode 100644 src/web/mobile/AutoRunDocumentViewer.tsx diff --git a/src/web/hooks/useAutoRun.ts b/src/web/hooks/useAutoRun.ts index 3e22540c9b..066ceafcc4 100644 --- a/src/web/hooks/useAutoRun.ts +++ b/src/web/hooks/useAutoRun.ts @@ -5,7 +5,7 @@ * and real-time document change tracking via WebSocket broadcasts. */ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useCallback } from 'react'; import type { UseWebSocketReturn, AutoRunState } from './useWebSocket'; /** @@ -67,9 +67,6 @@ export function useAutoRun( const [isLoadingDocs, setIsLoadingDocs] = useState(false); const [selectedDoc, setSelectedDoc] = useState(null); - // Track the current sessionId for auto-refresh - const currentSessionIdRef = useRef(null); - const loadDocuments = useCallback( async (sessionId: string) => { setIsLoadingDocs(true); diff --git a/src/web/mobile/AutoRunDocumentViewer.tsx b/src/web/mobile/AutoRunDocumentViewer.tsx new file mode 100644 index 0000000000..fc5f72c7ab --- /dev/null +++ b/src/web/mobile/AutoRunDocumentViewer.tsx @@ -0,0 +1,507 @@ +/** + * AutoRunDocumentViewer component for Maestro mobile web interface + * + * Full-screen document viewer/editor for Auto Run markdown files. + * Supports preview mode (rendered markdown) and edit mode (textarea). + * Loads content via WebSocket and saves explicitly on user action. + */ + +import { useState, useCallback, useEffect, useRef } from 'react'; +import { useThemeColors } from '../components/ThemeProvider'; +import { MobileMarkdownRenderer } from './MobileMarkdownRenderer'; +import { triggerHaptic, HAPTIC_PATTERNS } from './constants'; +import type { UseWebSocketReturn } from '../hooks/useWebSocket'; + +/** + * Props for AutoRunDocumentViewer component + */ +export interface AutoRunDocumentViewerProps { + sessionId: string; + filename: string; + onBack: () => void; + sendRequest: UseWebSocketReturn['sendRequest']; +} + +/** + * AutoRunDocumentViewer component + * + * Full-screen viewer/editor for Auto Run markdown documents. + * Default mode is preview (rendered markdown); toggle to edit mode for a textarea. + */ +export function AutoRunDocumentViewer({ + sessionId, + filename, + onBack, + sendRequest, +}: AutoRunDocumentViewerProps) { + const colors = useThemeColors(); + const [content, setContent] = useState(''); + const [editContent, setEditContent] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isEditing, setIsEditing] = useState(false); + const [isDirty, setIsDirty] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [saveMessage, setSaveMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null); + const textareaRef = useRef(null); + const saveMessageTimerRef = useRef | null>(null); + + // Load document content on mount + useEffect(() => { + let cancelled = false; + + async function loadContent() { + setIsLoading(true); + try { + const response = await sendRequest<{ content?: string }>( + 'get_auto_run_document', + { sessionId, filename }, + ); + if (!cancelled) { + const loaded = response.content ?? ''; + setContent(loaded); + setEditContent(loaded); + } + } catch { + if (!cancelled) { + setContent(''); + setEditContent(''); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + } + + loadContent(); + return () => { cancelled = true; }; + }, [sessionId, filename, sendRequest]); + + // Clear save message timer on unmount + useEffect(() => { + return () => { + if (saveMessageTimerRef.current) { + clearTimeout(saveMessageTimerRef.current); + } + }; + }, []); + + // Focus textarea when entering edit mode + useEffect(() => { + if (isEditing && textareaRef.current) { + textareaRef.current.focus(); + } + }, [isEditing]); + + const showSaveMessage = useCallback((text: string, type: 'success' | 'error') => { + setSaveMessage({ text, type }); + if (saveMessageTimerRef.current) { + clearTimeout(saveMessageTimerRef.current); + } + saveMessageTimerRef.current = setTimeout(() => { + setSaveMessage(null); + }, 2500); + }, []); + + const handleBack = useCallback(() => { + if (isDirty) { + const confirmed = window.confirm('You have unsaved changes. Discard and go back?'); + if (!confirmed) return; + } + triggerHaptic(HAPTIC_PATTERNS.tap); + onBack(); + }, [isDirty, onBack]); + + const handleToggleEdit = useCallback(() => { + triggerHaptic(HAPTIC_PATTERNS.tap); + if (isEditing) { + // Switching to preview — if dirty, keep editContent but show preview of editContent + setIsEditing(false); + } else { + // Switching to edit — sync editContent with latest + setEditContent(isDirty ? editContent : content); + setIsEditing(true); + } + }, [isEditing, isDirty, editContent, content]); + + const handleContentChange = useCallback((e: React.ChangeEvent) => { + const newContent = e.target.value; + setEditContent(newContent); + setIsDirty(newContent !== content); + }, [content]); + + const handleSave = useCallback(async () => { + triggerHaptic(HAPTIC_PATTERNS.tap); + setIsSaving(true); + try { + const response = await sendRequest<{ success?: boolean }>( + 'save_auto_run_document', + { sessionId, filename, content: editContent }, + ); + if (response.success) { + setContent(editContent); + setIsDirty(false); + triggerHaptic(HAPTIC_PATTERNS.success); + showSaveMessage('Saved', 'success'); + } else { + triggerHaptic(HAPTIC_PATTERNS.error); + showSaveMessage('Save failed', 'error'); + } + } catch { + triggerHaptic(HAPTIC_PATTERNS.error); + showSaveMessage('Save failed', 'error'); + } finally { + setIsSaving(false); + } + }, [sessionId, filename, editContent, sendRequest, showSaveMessage]); + + // Close on escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + handleBack(); + } + // Ctrl/Cmd+S to save when editing + if ((e.metaKey || e.ctrlKey) && e.key === 's' && isEditing && isDirty) { + e.preventDefault(); + handleSave(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [handleBack, handleSave, isEditing, isDirty]); + + // Display content: when dirty, show editContent in preview; otherwise show saved content + const displayContent = isDirty ? editContent : content; + + return ( +
+ {/* Header */} +
+ {/* Back button */} + + + {/* Filename title */} +
+ {filename} + {isDirty && ( + + (unsaved) + + )} +
+ + {/* Edit/Preview toggle */} + + + {/* Save button (when editing and dirty) */} + {isEditing && isDirty && ( + + )} +
+ + {/* Save message toast */} + {saveMessage && ( +
+ {saveMessage.text} +
+ )} + + {/* Content area */} +
+ {isLoading ? ( + // Loading spinner +
+ + + + + + + + + + + Loading document... +
+ ) : isEditing ? ( + // Edit mode: textarea +
+