refactor: Web interface as true remote control with code deduplication

Major architectural improvement to ensure web interface operations go through
the same code paths as desktop, eliminating duplicate logic and race conditions.

Key changes:
- Fix AgentDetector running 20+ times at startup via promise deduplication
- Web commands now forward to renderer's processInput instead of spawning directly
- Mode switching forwards to desktop instead of just acknowledging requests
- Interrupt forwards to desktop to properly update session state to idle
- Add IPC channels: remote:executeCommand, remote:switchMode, remote:interrupt
- Add mobile MessageHistory component for viewing session AI/shell logs

Architecture now ensures single source of truth:
  Web → IPC → Renderer (same code path as desktop)
This commit is contained in:
Pedram Amini
2025-11-28 00:53:01 -06:00
parent c444b06fb0
commit 0ecec1cc75
13 changed files with 736 additions and 158 deletions

10
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -68,15 +68,35 @@ const AGENT_DEFINITIONS: Omit<AgentConfig, 'available' | 'path'>[] = [
export class AgentDetector {
private cachedAgents: AgentConfig[] | null = null;
private detectionInProgress: Promise<AgentConfig[]> | 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<AgentConfig[]> {
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<AgentConfig[]> {
const agents: AgentConfig[] = [];
const expandedEnv = this.getExpandedEnv();

View File

@@ -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();

View File

@@ -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: {

View File

@@ -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<boolean>;
// 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<boolean>;
// 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<boolean>;
// 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)

View File

@@ -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)

View File

@@ -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<Map<string, number>>(new Map());
@@ -338,12 +357,11 @@ export function SessionList(props: SessionListProps) {
Scan with Mobile
</div>
<div className="p-2 rounded" style={{ backgroundColor: 'white' }}>
<QRCode
<QRCodeSVG
value={webInterfaceUrl}
size={100}
bgColor="#FFFFFF"
fgColor="#000000"
alt="Scan to open on mobile"
/>
</div>
</div>

View File

@@ -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]
);
/**

View File

@@ -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);

View File

@@ -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<LastResponsePreview | null>(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 (
<div
style={{
marginBottom: '24px',
padding: '16px',
textAlign: 'center',
}}
>
<p style={{ fontSize: '14px', color: colors.textDim }}>
{activeSession.inputMode === 'ai'
? 'Ask your AI assistant anything'
: 'Run shell commands'}
</p>
</div>
);
}
// Show last response in a cell format
// Show message history
return (
<div
style={{
@@ -838,65 +867,38 @@ export default function MobileApp() {
alignItems: 'stretch',
}}
>
{/* Response cell */}
<div
onClick={() => handleExpandResponse(sessionLastResponse)}
style={{
backgroundColor: colors.bgSidebar,
border: `1px solid ${colors.border}`,
borderRadius: '8px',
padding: '12px',
cursor: 'pointer',
textAlign: 'left',
}}
>
{isLoadingLogs ? (
<div
style={{
fontSize: '10px',
padding: '16px',
textAlign: 'center',
color: colors.textDim,
marginBottom: '6px',
textTransform: 'uppercase',
letterSpacing: '0.5px',
fontSize: '13px',
}}
>
{sessionLastResponse.source === 'stdout' ? 'AI Response' : sessionLastResponse.source}
Loading conversation...
</div>
) : currentLogs.length === 0 ? (
<div
style={{
fontSize: '13px',
color: colors.textMain,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'monospace',
lineHeight: 1.4,
maxHeight: '200px',
overflow: 'hidden',
position: 'relative',
padding: '16px',
textAlign: 'center',
color: colors.textDim,
fontSize: '14px',
}}
>
{sessionLastResponse.text}
{sessionLastResponse.fullLength > sessionLastResponse.text.length && (
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '40px',
background: `linear-gradient(transparent, ${colors.bgSidebar})`,
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
paddingBottom: '4px',
}}
>
<span style={{ fontSize: '11px', color: colors.accent }}>
Tap to see full response
</span>
</div>
)}
{activeSession.inputMode === 'ai'
? 'Ask your AI assistant anything'
: 'Run shell commands'}
</div>
</div>
) : (
<MessageHistory
logs={currentLogs}
inputMode={activeSession.inputMode as 'ai' | 'terminal'}
autoScroll={true}
maxHeight="calc(100vh - 350px)"
/>
)}
</div>
);
};

View File

@@ -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<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(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 (
<div
style={{
padding: '16px',
textAlign: 'center',
color: colors.textDim,
fontSize: '13px',
}}
>
No messages yet
</div>
);
}
return (
<div
ref={containerRef}
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '12px',
maxHeight,
overflowY: 'auto',
overflowX: 'hidden',
backgroundColor: colors.bgMain,
borderRadius: '8px',
border: `1px solid ${colors.border}`,
}}
>
{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 (
<div
key={entry.id || `${entry.timestamp}-${index}`}
onClick={() => 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 */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '10px',
color: colors.textDim,
}}
>
<span
style={{
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
color: isUser
? colors.accent
: isError
? colors.error
: colors.textDim,
}}
>
{isUser ? 'You' : isError ? 'Error' : isSystem ? 'System' : inputMode === 'ai' ? 'AI' : 'Output'}
</span>
<span style={{ opacity: 0.7 }}>{formatTime(entry.timestamp)}</span>
</div>
{/* Message content */}
<div
style={{
fontSize: '13px',
lineHeight: 1.5,
color: isError ? colors.error : colors.textMain,
fontFamily: inputMode === 'terminal' || isUser ? 'ui-monospace, monospace' : 'inherit',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
// Limit very long messages
maxHeight: '200px',
overflow: 'hidden',
position: 'relative',
}}
>
{text.length > 500 ? (
<>
{text.slice(0, 500)}
<span style={{ color: colors.textDim }}>... (tap to expand)</span>
</>
) : (
text
)}
</div>
</div>
);
})}
<div ref={bottomRef} />
</div>
);
}
export default MessageHistory;

View File

@@ -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 ".../<parent>/<current>" 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 */}
<div
@@ -705,36 +686,43 @@ export function SessionStatusBanner({
</span>
</div>
{/* Right side: Status indicator */}
{/* Right side: Session ID pill + Status indicator */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
gap: '8px',
flexShrink: 0,
paddingLeft: '12px',
}}
>
<StatusDot status={status} size="sm" />
{/* Session ID pill */}
<span
style={{
fontSize: '12px',
fontWeight: 500,
color:
status === 'idle'
? colors.success
: status === 'busy'
? colors.warning
: status === 'connecting'
? '#f97316' // Orange
: colors.error,
fontSize: '9px',
fontFamily: 'monospace',
color: colors.textDim,
backgroundColor: `${colors.textDim}15`,
padding: '2px 6px',
borderRadius: '4px',
letterSpacing: '0.5px',
}}
title={`Session ID: ${session.id}`}
>
{session.id.slice(0, 8)}
</span>
{/* Status dot only (no text for idle) */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
{statusLabel}
<StatusDot status={status} size="sm" />
{isThinking && <ThinkingIndicator />}
</span>
</div>
</div>
</div>