mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
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:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
188
src/web/mobile/MessageHistory.tsx
Normal file
188
src/web/mobile/MessageHistory.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user