diff --git a/package-lock.json b/package-lock.json index bdb4ea99..cae866af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "mermaid": "^11.12.1", "node-pty": "^1.0.0", "qrcode": "^1.5.4", + "qrcode.react": "^4.2.0", "react-diff-view": "^3.3.2", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", @@ -9937,6 +9938,15 @@ "node": ">=10.13.0" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/qrcode/node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", diff --git a/package.json b/package.json index 1dad5c7e..22c60108 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "mermaid": "^11.12.1", "node-pty": "^1.0.0", "qrcode": "^1.5.4", + "qrcode.react": "^4.2.0", "react-diff-view": "^3.3.2", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", diff --git a/src/main/agent-detector.ts b/src/main/agent-detector.ts index 8fb2051d..122c8c30 100644 --- a/src/main/agent-detector.ts +++ b/src/main/agent-detector.ts @@ -68,15 +68,35 @@ const AGENT_DEFINITIONS: Omit[] = [ export class AgentDetector { private cachedAgents: AgentConfig[] | null = null; + private detectionInProgress: Promise | null = null; /** * Detect which agents are available on the system + * Uses promise deduplication to prevent parallel detection when multiple calls arrive simultaneously */ async detectAgents(): Promise { if (this.cachedAgents) { return this.cachedAgents; } + // If detection is already in progress, return the same promise to avoid parallel runs + if (this.detectionInProgress) { + return this.detectionInProgress; + } + + // Start detection and track the promise + this.detectionInProgress = this.doDetectAgents(); + try { + return await this.detectionInProgress; + } finally { + this.detectionInProgress = null; + } + } + + /** + * Internal method that performs the actual agent detection + */ + private async doDetectAgents(): Promise { const agents: AgentConfig[] = []; const expandedEnv = this.getExpandedEnv(); diff --git a/src/main/index.ts b/src/main/index.ts index 93db30b1..593e63cc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -384,20 +384,50 @@ app.whenReady().then(() => { return result; }); - // Set up callback for web server to interrupt sessions - // Note: Interrupts go to the AI process (the one that's typically "busy") - webServer.setInterruptSessionCallback((sessionId: string) => { - if (!processManager) return false; + // Set up callback for web server to execute commands through the desktop + // This forwards AI commands to the renderer, ensuring single source of truth + // The renderer handles all spawn logic, state management, and broadcasts + webServer.setExecuteCommandCallback(async (sessionId: string, command: string) => { + if (!mainWindow) { + console.log('[executeCommand] mainWindow is null'); + return false; + } - // Get session's inputMode to determine which process to interrupt - const sessions = sessionsStore.get('sessions', []); - const session = sessions.find((s: any) => s.id === sessionId); - if (!session) return false; + // Forward to renderer - it will handle spawn, state, and everything else + // This ensures web commands go through exact same code path as desktop commands + console.log(`[executeCommand] Forwarding command to renderer for session ${sessionId}`); + mainWindow.webContents.send('remote:executeCommand', sessionId, command); + return true; + }); - // Interrupt the process based on current inputMode - const targetSessionId = session.inputMode === 'ai' ? `${sessionId}-ai` : `${sessionId}-terminal`; - console.log(`[interrupt] Interrupting ${targetSessionId}`); - return processManager.interrupt(targetSessionId); + // Set up callback for web server to interrupt sessions through the desktop + // This forwards to the renderer which handles state updates and broadcasts + webServer.setInterruptSessionCallback(async (sessionId: string) => { + if (!mainWindow) { + console.log('[interrupt] mainWindow is null'); + return false; + } + + // Forward to renderer - it will handle interrupt, state update, and broadcasts + // This ensures web interrupts go through exact same code path as desktop interrupts + console.log(`[interrupt] Forwarding interrupt to renderer for session ${sessionId}`); + mainWindow.webContents.send('remote:interrupt', sessionId); + return true; + }); + + // Set up callback for web server to switch session mode through the desktop + // This forwards to the renderer which handles state updates and broadcasts + webServer.setSwitchModeCallback(async (sessionId: string, mode: 'ai' | 'terminal') => { + if (!mainWindow) { + console.log('[switchMode] mainWindow is null'); + return false; + } + + // Forward to renderer - it will handle mode switch and broadcasts + // This ensures web mode switches go through exact same code path as desktop + console.log(`[switchMode] Forwarding mode switch to renderer for session ${sessionId}: ${mode}`); + mainWindow.webContents.send('remote:switchMode', sessionId, mode); + return true; }); logger.info('Core services initialized', 'Startup'); @@ -862,6 +892,12 @@ function setupIpcHandlers() { return webServer.getLiveSessions(); }); + ipcMain.handle('live:broadcastActiveSession', async (_, sessionId: string) => { + if (webServer) { + webServer.broadcastActiveSessionChange(sessionId); + } + }); + // Web server management ipcMain.handle('webserver:getUrl', async () => { return webServer?.getSecureUrl(); diff --git a/src/main/preload.ts b/src/main/preload.ts index dffbbfa9..cbdaf7cb 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -83,6 +83,25 @@ contextBridge.exposeInMainWorld('maestro', { ipcRenderer.on('process:session-id', handler); return () => ipcRenderer.removeListener('process:session-id', handler); }, + // Remote command execution from web interface + // This allows web commands to go through the same code path as desktop commands + onRemoteCommand: (callback: (sessionId: string, command: string) => void) => { + const handler = (_: any, sessionId: string, command: string) => callback(sessionId, command); + ipcRenderer.on('remote:executeCommand', handler); + return () => ipcRenderer.removeListener('remote:executeCommand', handler); + }, + // Remote mode switch from web interface - forwards to desktop's toggleInputMode logic + onRemoteSwitchMode: (callback: (sessionId: string, mode: 'ai' | 'terminal') => void) => { + const handler = (_: any, sessionId: string, mode: 'ai' | 'terminal') => callback(sessionId, mode); + ipcRenderer.on('remote:switchMode', handler); + return () => ipcRenderer.removeListener('remote:switchMode', handler); + }, + // Remote interrupt from web interface - forwards to desktop's handleInterrupt logic + onRemoteInterrupt: (callback: (sessionId: string) => void) => { + const handler = (_: any, sessionId: string) => callback(sessionId); + ipcRenderer.on('remote:interrupt', handler); + return () => ipcRenderer.removeListener('remote:interrupt', handler); + }, // Stderr listener for runCommand (separate stream) onStderr: (callback: (sessionId: string, data: string) => void) => { const handler = (_: any, sessionId: string, data: string) => callback(sessionId, data); @@ -143,6 +162,8 @@ contextBridge.exposeInMainWorld('maestro', { getStatus: (sessionId: string) => ipcRenderer.invoke('live:getStatus', sessionId), getDashboardUrl: () => ipcRenderer.invoke('live:getDashboardUrl'), getLiveSessions: () => ipcRenderer.invoke('live:getLiveSessions'), + broadcastActiveSession: (sessionId: string) => + ipcRenderer.invoke('live:broadcastActiveSession', sessionId), }, // Agent API @@ -277,6 +298,9 @@ export interface MaestroAPI { onData: (callback: (sessionId: string, data: string) => void) => () => void; onExit: (callback: (sessionId: string, code: number) => void) => () => void; onSessionId: (callback: (sessionId: string, claudeSessionId: string) => void) => () => void; + onRemoteCommand: (callback: (sessionId: string, command: string) => void) => () => void; + onRemoteSwitchMode: (callback: (sessionId: string, mode: 'ai' | 'terminal') => void) => () => void; + onRemoteInterrupt: (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: { diff --git a/src/main/web-server.ts b/src/main/web-server.ts index 3728d208..414f27d1 100644 --- a/src/main/web-server.ts +++ b/src/main/web-server.ts @@ -9,7 +9,7 @@ import { randomUUID } from 'crypto'; import path from 'path'; import { existsSync, readFileSync } from 'fs'; import type { Theme } from '../shared/theme-types'; -import { getLocalIpAddress } from './utils/networkUtils'; +import { getLocalIpAddressSync } from './utils/networkUtils'; const GITHUB_REDIRECT_URL = 'https://github.com/pedramamini/Maestro'; @@ -127,9 +127,24 @@ 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; +// Callback type for executing a command through the desktop's existing logic +// This forwards the command to the renderer which handles spawn, state, and broadcasts +// Returns true if command was accepted (session not busy) +export type ExecuteCommandCallback = ( + sessionId: string, + command: string +) => Promise; + +// Callback type for interrupting a session through the desktop's existing logic +// This forwards to the renderer which handles state updates and broadcasts +export type InterruptSessionCallback = (sessionId: string) => Promise; + +// Callback type for switching session input mode through the desktop's existing logic +// This forwards to the renderer which handles state updates and broadcasts +export type SwitchModeCallback = ( + sessionId: string, + mode: 'ai' | 'terminal' +) => Promise; // Re-export Theme type from shared for backwards compatibility export type { Theme } from '../shared/theme-types'; @@ -156,7 +171,9 @@ export class WebServer { private getSessionDetailCallback: GetSessionDetailCallback | null = null; private getThemeCallback: GetThemeCallback | null = null; private writeToSessionCallback: WriteToSessionCallback | null = null; + private executeCommandCallback: ExecuteCommandCallback | null = null; private interruptSessionCallback: InterruptSessionCallback | null = null; + private switchModeCallback: SwitchModeCallback | null = null; private webAssetsPath: string | null = null; // Security token - regenerated on each app startup @@ -383,13 +400,29 @@ export class WebServer { } /** - * Set the callback function for interrupting a session - * This is called by the /api/session/:id/interrupt endpoint + * Set the callback function for executing commands through the desktop + * This forwards commands to the renderer which handles spawn, state management, and broadcasts + */ + setExecuteCommandCallback(callback: ExecuteCommandCallback) { + this.executeCommandCallback = callback; + } + + /** + * Set the callback function for interrupting a session through the desktop + * This forwards to the renderer which handles state updates and broadcasts */ setInterruptSessionCallback(callback: InterruptSessionCallback) { this.interruptSessionCallback = callback; } + /** + * Set the callback function for switching session mode through the desktop + * This forwards to the renderer which handles state updates and broadcasts + */ + setSwitchModeCallback(callback: SwitchModeCallback) { + this.switchModeCallback = callback; + } + /** * Set the rate limiting configuration */ @@ -709,22 +742,30 @@ export class WebServer { }); } - const success = this.interruptSessionCallback(id); - if (!success) { + try { + // Forward to desktop's interrupt logic - handles state updates and broadcasts + const success = await this.interruptSessionCallback(id); + if (!success) { + return reply.code(500).send({ + error: 'Internal Server Error', + message: 'Failed to interrupt session', + timestamp: Date.now(), + }); + } + + return { + success: true, + message: 'Interrupt signal sent successfully', + sessionId: id, + timestamp: Date.now(), + }; + } catch (error: any) { return reply.code(500).send({ error: 'Internal Server Error', - message: 'Failed to interrupt session', + message: `Failed to interrupt session: ${error.message}`, timestamp: Date.now(), }); - return; } - - return { - success: true, - message: 'Interrupt signal sent successfully', - sessionId: id, - timestamp: Date.now(), - }; }); } @@ -860,12 +901,55 @@ export class WebServer { return; } - // Note: We don't check isSessionLive() here because the web interface - // should be able to send commands to any valid session. The callback - // validates the session exists and the security token protects access. + // Get session details to check state and determine how to handle + const sessionDetail = this.getSessionDetailCallback?.(sessionId); + if (!sessionDetail) { + client.socket.send(JSON.stringify({ + type: 'error', + message: 'Session not found', + timestamp: Date.now(), + })); + return; + } - // Write command to session - if (this.writeToSessionCallback) { + // Check if session is busy - prevent race conditions between desktop and web + if (sessionDetail.state === 'busy') { + client.socket.send(JSON.stringify({ + type: 'error', + message: 'Session is busy - please wait for the current operation to complete', + sessionId, + timestamp: Date.now(), + })); + console.log(`[WebSocket] Command rejected - session ${sessionId} is busy`); + return; + } + + const isAiMode = sessionDetail.inputMode === 'ai'; + + if (isAiMode && this.executeCommandCallback) { + // AI mode: forward to desktop's command execution logic + // This ensures single source of truth - desktop handles spawn, state, and broadcasts + console.log(`[WebSocket] Forwarding AI command to desktop for session ${sessionId}`); + + this.executeCommandCallback(sessionId, command) + .then((success) => { + client.socket.send(JSON.stringify({ + type: 'command_result', + success, + sessionId, + timestamp: Date.now(), + })); + console.log(`[WebSocket] Command forwarded to desktop for session ${sessionId}: ${success ? 'accepted' : 'rejected'}`); + }) + .catch((error) => { + client.socket.send(JSON.stringify({ + type: 'error', + message: `Failed to execute command: ${error.message}`, + timestamp: Date.now(), + })); + }); + } else if (this.writeToSessionCallback) { + // Terminal mode: write directly to process const success = this.writeToSessionCallback(sessionId, command + '\n'); client.socket.send(JSON.stringify({ type: 'command_result', @@ -873,11 +957,11 @@ export class WebServer { sessionId, timestamp: Date.now(), })); - console.log(`[WebSocket] Command sent to session ${sessionId}: ${success ? 'success' : 'failed'}`); + console.log(`[WebSocket] Terminal command sent to session ${sessionId}: ${success ? 'success' : 'failed'}`); } else { client.socket.send(JSON.stringify({ type: 'error', - message: 'Write callback not configured', + message: 'Command execution not configured', timestamp: Date.now(), })); } @@ -898,15 +982,36 @@ export class WebServer { return; } - // Mode switching is handled by the main process - // We just acknowledge the request - the actual switch happens via IPC - client.socket.send(JSON.stringify({ - type: 'mode_switch_requested', - sessionId, - mode, - timestamp: Date.now(), - })); - console.log(`[WebSocket] Mode switch requested for session ${sessionId}: ${mode}`); + if (!this.switchModeCallback) { + client.socket.send(JSON.stringify({ + type: 'error', + message: 'Mode switching not configured', + timestamp: Date.now(), + })); + return; + } + + // Forward to desktop's mode switching logic + // This ensures single source of truth - desktop handles state updates and broadcasts + console.log(`[WebSocket] Forwarding mode switch to desktop for session ${sessionId}: ${mode}`); + this.switchModeCallback(sessionId, mode) + .then((success) => { + client.socket.send(JSON.stringify({ + type: 'mode_switch_result', + success, + sessionId, + mode, + timestamp: Date.now(), + })); + console.log(`[WebSocket] Mode switch for session ${sessionId} to ${mode}: ${success ? 'success' : 'failed'}`); + }) + .catch((error) => { + client.socket.send(JSON.stringify({ + type: 'error', + message: `Failed to switch mode: ${error.message}`, + timestamp: Date.now(), + })); + }); break; } @@ -1043,6 +1148,18 @@ export class WebServer { }); } + /** + * Broadcast active session change to all connected web clients + * Called when the user switches sessions in the desktop app + */ + broadcastActiveSessionChange(sessionId: string) { + this.broadcastToWebClients({ + type: 'active_session_changed', + sessionId, + timestamp: Date.now(), + }); + } + /** * Broadcast theme change to all connected web clients * Called when the user changes the theme in the desktop app @@ -1072,8 +1189,8 @@ export class WebServer { } try { - // Detect local IP address for LAN accessibility - this.localIpAddress = await getLocalIpAddress(); + // Detect local IP address for LAN accessibility (sync - no network delay) + this.localIpAddress = getLocalIpAddressSync(); console.log(`Web server using IP address: ${this.localIpAddress}`); // Setup middleware and routes (must be done before listen) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 73ca5ee2..ffbfc274 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -834,6 +834,10 @@ export default function MaestroConsole() { sessionsRef.current = sessions; updateGlobalStatsRef.current = updateGlobalStats; + // Ref for handling remote commands from web interface + // This allows web commands to go through the exact same code path as desktop commands + const pendingRemoteCommandRef = useRef<{ sessionId: string; command: string } | null>(null); + // Expose addToast to window for debugging/testing useEffect(() => { (window as any).__maestroDebug = { @@ -863,6 +867,133 @@ export default function MaestroConsole() { ); const theme = THEMES[activeThemeId]; + // Broadcast active session change to web clients + useEffect(() => { + if (activeSessionId && isLiveMode) { + window.maestro.live.broadcastActiveSession(activeSessionId); + } + }, [activeSessionId, isLiveMode]); + + // Handle remote commands from web interface + // This allows web commands to go through the exact same code path as desktop commands + useEffect(() => { + const unsubscribeRemote = window.maestro.process.onRemoteCommand((sessionId: string, command: string) => { + console.log('[Remote] Received command from web interface:', { sessionId, command: command.substring(0, 50) }); + + // Verify the session exists + const targetSession = sessionsRef.current.find(s => s.id === sessionId); + if (!targetSession) { + console.log('[Remote] Session not found:', sessionId); + return; + } + + // Check if session is busy (should have been checked by web server, but double-check) + if (targetSession.state === 'busy') { + console.log('[Remote] Session is busy, rejecting command:', sessionId); + return; + } + + // Store the pending command + pendingRemoteCommandRef.current = { sessionId, command }; + + // Switch to the target session and set the input + // The input change will trigger processInput via the pendingRemoteCommandRef + setActiveSessionId(sessionId); + setInputValue(command); + + // Use setTimeout to ensure state updates have been applied before processing + // This ensures processInput sees the correct activeSession and inputValue + setTimeout(() => { + if (pendingRemoteCommandRef.current?.sessionId === sessionId) { + // Trigger a custom event that the input handler can respond to + // This is cleaner than trying to call processInput directly + window.dispatchEvent(new CustomEvent('maestro:remoteCommand', { + detail: { sessionId, command } + })); + pendingRemoteCommandRef.current = null; + } + }, 50); + }); + + return () => { + unsubscribeRemote(); + }; + }, []); + + // Handle remote mode switches from web interface + // This allows web mode switches to go through the same code path as desktop + useEffect(() => { + const unsubscribeSwitchMode = window.maestro.process.onRemoteSwitchMode((sessionId: string, mode: 'ai' | 'terminal') => { + console.log('[Remote] Received mode switch from web interface:', { sessionId, mode }); + + // Find the session and update its mode + setSessions(prev => { + const session = prev.find(s => s.id === sessionId); + if (!session) { + console.log('[Remote] Session not found for mode switch:', sessionId); + return prev; + } + + // Only switch if mode is different + if (session.inputMode === mode) { + console.log('[Remote] Session already in mode:', mode); + return prev; + } + + console.log('[Remote] Switching session mode:', sessionId, 'to', mode); + return prev.map(s => { + if (s.id !== sessionId) return s; + return { ...s, inputMode: mode }; + }); + }); + }); + + return () => { + unsubscribeSwitchMode(); + }; + }, []); + + // Handle remote interrupts from web interface + // This allows web interrupts to go through the same code path as desktop (handleInterrupt) + useEffect(() => { + const unsubscribeInterrupt = window.maestro.process.onRemoteInterrupt(async (sessionId: string) => { + console.log('[Remote] Received interrupt from web interface:', { sessionId }); + + // Find the session + const session = sessionsRef.current.find(s => s.id === sessionId); + if (!session) { + console.log('[Remote] Session not found for interrupt:', sessionId); + return; + } + + // Use the same logic as handleInterrupt + const currentMode = session.inputMode; + const targetSessionId = currentMode === 'ai' ? `${session.id}-ai` : `${session.id}-terminal`; + + try { + // Send interrupt signal (Ctrl+C) + await window.maestro.process.interrupt(targetSessionId); + + // Set state to idle (same as handleInterrupt) + setSessions(prev => prev.map(s => { + if (s.id !== session.id) return s; + return { + ...s, + state: 'idle' + }; + })); + + console.log('[Remote] Interrupt successful for session:', sessionId); + } catch (error) { + console.error('[Remote] Failed to interrupt session:', error); + } + }); + + return () => { + unsubscribeInterrupt(); + }; + }, []); + // Combine built-in slash commands with custom AI commands for autocomplete const allSlashCommands = useMemo(() => { const customCommandsAsSlash = customAICommands.map(cmd => ({ @@ -2359,6 +2490,17 @@ export default function MaestroConsole() { } }; + // Listen for remote commands from web interface + // This event is triggered by the remote command handler after setting input state + useEffect(() => { + const handleRemoteCommand = () => { + console.log('[Remote] Processing remote command via event'); + processInput(); + }; + window.addEventListener('maestro:remoteCommand', handleRemoteCommand); + return () => window.removeEventListener('maestro:remoteCommand', handleRemoteCommand); + }, [processInput]); + // Process a queued message (called from onExit when queue has items) const processQueuedMessage = async (sessionId: string, entry: LogEntry) => { // Use sessionsRef.current to get the latest session state (avoids stale closure) diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx index b2cc53f2..45a3e691 100644 --- a/src/renderer/components/SessionList.tsx +++ b/src/renderer/components/SessionList.tsx @@ -4,7 +4,7 @@ import { Radio, Copy, ExternalLink, PanelLeftClose, PanelLeftOpen, Folder, Info, FileText, GitBranch, Bot, Clock, ScrollText, Cpu, Menu, Bookmark, Tag } from 'lucide-react'; -import QRCode from 'qrcode.react'; +import { QRCodeSVG } from 'qrcode.react'; import type { Session, Group, Theme, Shortcut } from '../types'; import { getStatusColor, getContextColor, formatActiveTime } from '../utils/theme'; import { gitService } from '../services/git'; @@ -141,6 +141,25 @@ export function SessionList(props: SessionListProps) { } }, [menuOpen]); + // Close overlays/menus with Escape key + useEffect(() => { + const handleEscKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (liveOverlayOpen) { + setLiveOverlayOpen(false); + e.stopPropagation(); + } else if (menuOpen) { + setMenuOpen(false); + e.stopPropagation(); + } + } + }; + if (liveOverlayOpen || menuOpen) { + document.addEventListener('keydown', handleEscKey); + return () => document.removeEventListener('keydown', handleEscKey); + } + }, [liveOverlayOpen, menuOpen]); + // Track git file change counts per session const [gitFileCounts, setGitFileCounts] = useState>(new Map()); @@ -338,12 +357,11 @@ export function SessionList(props: SessionListProps) { Scan with Mobile
-
diff --git a/src/web/hooks/useCommandHistory.ts b/src/web/hooks/useCommandHistory.ts index a6ccb646..9f0b2160 100644 --- a/src/web/hooks/useCommandHistory.ts +++ b/src/web/hooks/useCommandHistory.ts @@ -208,7 +208,18 @@ export function useCommandHistory( ); /** - * Get unique commands (deduplicated by command text, most recent first) + * Normalize command for deduplication comparison + * - Lowercase + * - Trim whitespace + * - Remove trailing punctuation (?, !, .) + */ + const normalizeForDedup = useCallback((command: string): string => { + return command.toLowerCase().trim().replace(/[?!.]+$/, ''); + }, []); + + /** + * Get unique commands (deduplicated by normalized text, most recent first) + * Deduplication ignores case and trailing punctuation */ const getUniqueCommands = useCallback( (count = 5): CommandHistoryEntry[] => { @@ -216,8 +227,9 @@ export function useCommandHistory( const unique: CommandHistoryEntry[] = []; for (const entry of history) { - if (!seen.has(entry.command)) { - seen.add(entry.command); + const normalized = normalizeForDedup(entry.command); + if (!seen.has(normalized)) { + seen.add(normalized); unique.push(entry); if (unique.length >= count) break; } @@ -225,7 +237,7 @@ export function useCommandHistory( return unique; }, - [history] + [history, normalizeForDedup] ); /** diff --git a/src/web/hooks/useWebSocket.ts b/src/web/hooks/useWebSocket.ts index 4199fa2c..de583d07 100644 --- a/src/web/hooks/useWebSocket.ts +++ b/src/web/hooks/useWebSocket.ts @@ -55,6 +55,7 @@ export interface SessionData { groupEmoji?: string | null; usageStats?: UsageStats | null; lastResponse?: LastResponsePreview | null; + claudeSessionId?: string | null; } /** @@ -69,6 +70,7 @@ export type ServerMessageType = | 'session_state_change' | 'session_added' | 'session_removed' + | 'active_session_changed' | 'theme' | 'pong' | 'subscribed' @@ -157,6 +159,15 @@ export interface SessionRemovedMessage extends ServerMessage { sessionId: string; } +/** + * Active session changed message from server + * Sent when the desktop app switches to a different session + */ +export interface ActiveSessionChangedMessage extends ServerMessage { + type: 'active_session_changed'; + sessionId: string; +} + /** * Theme message from server */ @@ -185,6 +196,7 @@ export type TypedServerMessage = | SessionStateChangeMessage | SessionAddedMessage | SessionRemovedMessage + | ActiveSessionChangedMessage | ThemeMessage | ErrorMessage | ServerMessage; @@ -201,6 +213,8 @@ export interface WebSocketEventHandlers { onSessionAdded?: (session: SessionData) => void; /** Called when a session is removed */ onSessionRemoved?: (sessionId: string) => void; + /** Called when the active session changes on the desktop */ + onActiveSessionChanged?: (sessionId: string) => void; /** Called when theme is received or updated */ onThemeUpdate?: (theme: Theme) => void; /** Called when connection state changes */ @@ -449,6 +463,12 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet break; } + case 'active_session_changed': { + const activeMsg = message as ActiveSessionChangedMessage; + handlersRef.current?.onActiveSessionChanged?.(activeMsg.sessionId); + break; + } + case 'theme': { const themeMsg = message as ThemeMessage; handlersRef.current?.onThemeUpdate?.(themeMsg.theme); diff --git a/src/web/mobile/App.tsx b/src/web/mobile/App.tsx index f93bd80e..c1e36dc1 100644 --- a/src/web/mobile/App.tsx +++ b/src/web/mobile/App.tsx @@ -27,6 +27,7 @@ import { SessionStatusBanner } from './SessionStatusBanner'; import { ResponseViewer, type ResponseItem } from './ResponseViewer'; import { OfflineQueueBanner } from './OfflineQueueBanner'; import { ConnectionStatusIndicator } from './ConnectionStatusIndicator'; +import { MessageHistory, type LogEntry } from './MessageHistory'; import type { Session, LastResponsePreview } from '../hooks/useSessions'; /** @@ -198,6 +199,13 @@ export default function MobileApp() { const [selectedResponse, setSelectedResponse] = useState(null); const [responseIndex, setResponseIndex] = useState(0); + // Message history state (logs from active session) + const [sessionLogs, setSessionLogs] = useState<{ aiLogs: LogEntry[]; shellLogs: LogEntry[] }>({ + aiLogs: [], + shellLogs: [], + }); + const [isLoadingLogs, setIsLoadingLogs] = useState(false); + // Command history hook const { history: commandHistory, @@ -386,6 +394,11 @@ export default function MobileApp() { setSessions(prev => prev.filter(s => s.id !== sessionId)); setActiveSessionId(prev => prev === sessionId ? null : prev); }, + onActiveSessionChanged: (sessionId: string) => { + // Desktop app switched to a different session - sync with web + console.log('[Mobile] Desktop active session changed:', sessionId); + setActiveSessionId(sessionId); + }, }), [showResponseNotification]); const { state: connectionState, connect, send, error, reconnectAttempts } = useWebSocket({ @@ -400,6 +413,40 @@ export default function MobileApp() { connect(); }, [connect]); + // Fetch session logs when active session changes + useEffect(() => { + if (!activeSessionId || isOffline) { + setSessionLogs({ aiLogs: [], shellLogs: [] }); + return; + } + + const fetchSessionLogs = async () => { + setIsLoadingLogs(true); + try { + const apiUrl = buildApiUrl(`/session/${activeSessionId}`); + const response = await fetch(apiUrl); + if (response.ok) { + const data = await response.json(); + const session = data.session; + setSessionLogs({ + aiLogs: session?.aiLogs || [], + shellLogs: session?.shellLogs || [], + }); + console.log('[Mobile] Fetched session logs:', { + aiLogs: session?.aiLogs?.length || 0, + shellLogs: session?.shellLogs?.length || 0, + }); + } + } catch (err) { + console.error('[Mobile] Failed to fetch session logs:', err); + } finally { + setIsLoadingLogs(false); + } + }; + + fetchSessionLogs(); + }, [activeSessionId, isOffline]); + // Update sendRef after WebSocket is initialized useEffect(() => { sendRef.current = (sessionId: string, command: string) => { @@ -805,28 +852,10 @@ export default function MobileApp() { ); } - // Get the last response for the active session - const sessionLastResponse = (activeSession as any).lastResponse as LastResponsePreview | null; + // Get logs based on current input mode + const currentLogs = activeSession.inputMode === 'ai' ? sessionLogs.aiLogs : sessionLogs.shellLogs; - if (!sessionLastResponse) { - return ( -
-

- {activeSession.inputMode === 'ai' - ? 'Ask your AI assistant anything' - : 'Run shell commands'} -

-
- ); - } - - // Show last response in a cell format + // Show message history return (
- {/* Response cell */} -
handleExpandResponse(sessionLastResponse)} - style={{ - backgroundColor: colors.bgSidebar, - border: `1px solid ${colors.border}`, - borderRadius: '8px', - padding: '12px', - cursor: 'pointer', - textAlign: 'left', - }} - > + {isLoadingLogs ? (
- {sessionLastResponse.source === 'stdout' ? 'AI Response' : sessionLastResponse.source} + Loading conversation...
+ ) : currentLogs.length === 0 ? (
- {sessionLastResponse.text} - {sessionLastResponse.fullLength > sessionLastResponse.text.length && ( -
- - Tap to see full response - -
- )} + {activeSession.inputMode === 'ai' + ? 'Ask your AI assistant anything' + : 'Run shell commands'}
-
+ ) : ( + + )}
); }; diff --git a/src/web/mobile/MessageHistory.tsx b/src/web/mobile/MessageHistory.tsx new file mode 100644 index 00000000..47e031a4 --- /dev/null +++ b/src/web/mobile/MessageHistory.tsx @@ -0,0 +1,188 @@ +/** + * MessageHistory component for Maestro mobile web interface + * + * Displays the conversation history (AI logs and shell logs) for the active session. + * Shows messages in a scrollable container with user/AI differentiation. + */ + +import { useEffect, useRef } from 'react'; +import { useThemeColors } from '../components/ThemeProvider'; + +export interface LogEntry { + id?: string; + timestamp: number; + text?: string; + content?: string; + source?: 'user' | 'stdout' | 'stderr' | 'system'; + type?: string; +} + +export interface MessageHistoryProps { + /** Log entries to display */ + logs: LogEntry[]; + /** Input mode to determine which logs to show */ + inputMode: 'ai' | 'terminal'; + /** Whether to auto-scroll to bottom on new messages */ + autoScroll?: boolean; + /** Max height of the container */ + maxHeight?: string; + /** Callback when user taps a message */ + onMessageTap?: (entry: LogEntry) => void; +} + +/** + * Format timestamp for display + */ +function formatTime(timestamp: number): string { + const date = new Date(timestamp); + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +/** + * MessageHistory component + */ +export function MessageHistory({ + logs, + inputMode, + autoScroll = true, + maxHeight = '300px', + onMessageTap, +}: MessageHistoryProps) { + const colors = useThemeColors(); + const containerRef = useRef(null); + const bottomRef = useRef(null); + + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + if (autoScroll && bottomRef.current) { + bottomRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [logs, autoScroll]); + + if (!logs || logs.length === 0) { + return ( +
+ No messages yet +
+ ); + } + + return ( +
+ {logs.map((entry, index) => { + const text = entry.text || entry.content || ''; + const source = entry.source || (entry.type === 'user' ? 'user' : 'stdout'); + const isUser = source === 'user'; + const isError = source === 'stderr'; + const isSystem = source === 'system'; + + return ( +
onMessageTap?.(entry)} + style={{ + display: 'flex', + flexDirection: 'column', + gap: '4px', + padding: '10px 12px', + borderRadius: '8px', + backgroundColor: isUser + ? `${colors.accent}15` + : isError + ? `${colors.error}10` + : isSystem + ? `${colors.textDim}10` + : colors.bgSidebar, + border: `1px solid ${isUser + ? `${colors.accent}30` + : isError + ? `${colors.error}30` + : colors.border + }`, + cursor: onMessageTap ? 'pointer' : 'default', + // Align user messages to the right + alignSelf: isUser ? 'flex-end' : 'flex-start', + maxWidth: '90%', + }} + > + {/* Header: source and time */} +
+ + {isUser ? 'You' : isError ? 'Error' : isSystem ? 'System' : inputMode === 'ai' ? 'AI' : 'Output'} + + {formatTime(entry.timestamp)} +
+ + {/* Message content */} +
+ {text.length > 500 ? ( + <> + {text.slice(0, 500)} + ... (tap to expand) + + ) : ( + text + )} +
+
+ ); + })} +
+
+ ); +} + +export default MessageHistory; diff --git a/src/web/mobile/SessionStatusBanner.tsx b/src/web/mobile/SessionStatusBanner.tsx index 4daa65af..0c7796eb 100644 --- a/src/web/mobile/SessionStatusBanner.tsx +++ b/src/web/mobile/SessionStatusBanner.tsx @@ -36,24 +36,6 @@ export interface SessionStatusBannerProps { onExpandResponse?: (lastResponse: LastResponsePreview) => void; } -/** - * Get a human-readable status label based on session state - */ -function getStatusLabel(state: string): string { - switch (state) { - case 'idle': - return 'Ready'; - case 'busy': - return 'Thinking...'; - case 'connecting': - return 'Connecting...'; - case 'error': - return 'Error'; - default: - return 'Unknown'; - } -} - /** * Truncate a file path for display, preserving the most relevant parts * Shows "...//" format for long paths @@ -605,7 +587,6 @@ export function SessionStatusBanner({ ? sessionState as SessionStatus : 'error'; const isThinking = sessionState === 'busy'; - const statusLabel = getStatusLabel(sessionState); const truncatedCwd = truncatePath(session.cwd); // Access lastResponse from session (if available from web data) @@ -623,7 +604,7 @@ export function SessionStatusBanner({ }} role="status" aria-live="polite" - aria-label={`Current session: ${session.name}, status: ${statusLabel}`} + aria-label={`Current session: ${session.name}, status: ${status}`} > {/* Main status row */}
- {/* Right side: Status indicator */} + {/* Right side: Session ID pill + Status indicator */}
- + {/* Session ID pill */} + {session.id.slice(0, 8)} + + + {/* Status dot only (no text for idle) */} +
- {statusLabel} + {isThinking && } - +