diff --git a/src/web/hooks/index.ts b/src/web/hooks/index.ts index add57f49..46191b05 100644 --- a/src/web/hooks/index.ts +++ b/src/web/hooks/index.ts @@ -30,3 +30,16 @@ export type { UseWebSocketOptions, UseWebSocketReturn, } from './useWebSocket'; + +export { + useSessions, + default as useSessionsDefault, +} from './useSessions'; + +export type { + Session, + SessionState, + InputMode, + UseSessionsOptions, + UseSessionsReturn, +} from './useSessions'; diff --git a/src/web/hooks/useSessions.ts b/src/web/hooks/useSessions.ts new file mode 100644 index 00000000..bfece21f --- /dev/null +++ b/src/web/hooks/useSessions.ts @@ -0,0 +1,496 @@ +/** + * useSessions hook for Maestro web interface + * + * Provides real-time session state management for the web interface. + * Uses the WebSocket connection to receive session updates and provides + * methods to interact with sessions (send commands, interrupt, etc.). + */ + +import { useState, useCallback, useMemo, useRef, useEffect } from 'react'; +import { + useWebSocket, + type SessionData, + type UseWebSocketOptions, + type UseWebSocketReturn, + type WebSocketState, +} from './useWebSocket'; +import type { Theme } from '../../shared/theme-types'; + +/** + * Extended session data with client-side state + */ +export interface Session extends SessionData { + /** Whether commands are currently being sent to this session */ + isSending?: boolean; + /** Last error for this session */ + lastError?: string; +} + +/** + * Session state type (matches the desktop app's session states) + * - idle: Ready/Green + * - busy: Agent thinking/Yellow + * - error: No connection/Red + * - connecting: Pulsing Orange + */ +export type SessionState = 'idle' | 'busy' | 'error' | 'connecting'; + +/** + * Input mode type + * - ai: AI mode (interacting with AI agents) + * - terminal: Command terminal mode + */ +export type InputMode = 'ai' | 'terminal'; + +/** + * Options for the useSessions hook + */ +export interface UseSessionsOptions extends Omit { + /** Whether to automatically connect on mount */ + autoConnect?: boolean; + /** Called when theme updates from server */ + onThemeUpdate?: (theme: Theme) => void; + /** Called when sessions list changes */ + onSessionsChange?: (sessions: Session[]) => void; + /** Called when active session changes */ + onActiveSessionChange?: (session: Session | null) => void; + /** Called when an error occurs */ + onError?: (error: string) => void; +} + +/** + * Return type for the useSessions hook + */ +export interface UseSessionsReturn { + /** All sessions */ + sessions: Session[]; + /** Sessions organized by group */ + sessionsByGroup: Record; + /** Currently active/selected session */ + activeSession: Session | null; + /** Set the active session by ID */ + setActiveSessionId: (sessionId: string | null) => void; + /** Get a session by ID */ + getSession: (sessionId: string) => Session | undefined; + + /** WebSocket connection state */ + connectionState: WebSocketState; + /** Whether connected and authenticated */ + isConnected: boolean; + /** Connection error message */ + connectionError: string | null; + /** Client ID assigned by server */ + clientId: string | null; + + /** Connect to the server */ + connect: () => void; + /** Disconnect from the server */ + disconnect: () => void; + /** Authenticate with a token */ + authenticate: (token: string) => void; + + /** Send a command to a session */ + sendCommand: (sessionId: string, command: string) => Promise; + /** Send a command to the active session */ + sendToActive: (command: string) => Promise; + /** Interrupt a session */ + interrupt: (sessionId: string) => Promise; + /** Interrupt the active session */ + interruptActive: () => Promise; + /** Switch session mode (AI/Terminal) */ + switchMode: (sessionId: string, mode: InputMode) => Promise; + + /** Refresh the sessions list from the server */ + refreshSessions: () => void; + + /** The underlying WebSocket hook return (for advanced use) */ + ws: UseWebSocketReturn; +} + +/** + * useSessions hook for managing sessions in the Maestro web interface + * + * @example + * ```tsx + * function App() { + * const { + * sessions, + * activeSession, + * setActiveSessionId, + * sendToActive, + * isConnected, + * connect, + * } = useSessions({ + * autoConnect: true, + * onThemeUpdate: (theme) => setTheme(theme), + * }); + * + * if (!isConnected) { + * return ; + * } + * + * return ( + *
+ * + * sendToActive(cmd)} + * disabled={!activeSession} + * /> + *
+ * ); + * } + * ``` + */ +export function useSessions(options: UseSessionsOptions = {}): UseSessionsReturn { + const { + autoConnect = false, + onThemeUpdate, + onSessionsChange, + onActiveSessionChange, + onError, + ...wsOptions + } = options; + + // State + const [sessions, setSessions] = useState([]); + const [activeSessionId, setActiveSessionIdState] = useState(null); + + // Refs for callbacks to avoid stale closures + const onThemeUpdateRef = useRef(onThemeUpdate); + const onSessionsChangeRef = useRef(onSessionsChange); + const onActiveSessionChangeRef = useRef(onActiveSessionChange); + const onErrorRef = useRef(onError); + + useEffect(() => { + onThemeUpdateRef.current = onThemeUpdate; + onSessionsChangeRef.current = onSessionsChange; + onActiveSessionChangeRef.current = onActiveSessionChange; + onErrorRef.current = onError; + }, [onThemeUpdate, onSessionsChange, onActiveSessionChange, onError]); + + /** + * Handle full sessions list update + */ + const handleSessionsUpdate = useCallback((newSessions: SessionData[]) => { + setSessions((prev) => { + // Preserve client-side state (isSending, lastError) from previous sessions + const sessionsMap = new Map(prev.map((s) => [s.id, s])); + const updated = newSessions.map((session) => { + const existing = sessionsMap.get(session.id); + return { + ...session, + isSending: existing?.isSending, + lastError: existing?.lastError, + }; + }); + return updated; + }); + onSessionsChangeRef.current?.(sessions); + }, [sessions]); + + /** + * Handle individual session state change + */ + const handleSessionStateChange = useCallback( + (sessionId: string, state: string, additionalData?: Partial) => { + setSessions((prev) => { + const index = prev.findIndex((s) => s.id === sessionId); + if (index === -1) return prev; + + const updated = [...prev]; + updated[index] = { + ...updated[index], + state, + ...additionalData, + }; + return updated; + }); + }, + [] + ); + + /** + * Handle session added + */ + const handleSessionAdded = useCallback((session: SessionData) => { + setSessions((prev) => { + // Check if session already exists + if (prev.some((s) => s.id === session.id)) { + return prev; + } + return [...prev, session]; + }); + }, []); + + /** + * Handle session removed + */ + const handleSessionRemoved = useCallback((sessionId: string) => { + setSessions((prev) => prev.filter((s) => s.id !== sessionId)); + + // If the removed session was active, clear the active session + setActiveSessionIdState((currentActive) => + currentActive === sessionId ? null : currentActive + ); + }, []); + + /** + * Handle theme update from server + */ + const handleThemeUpdate = useCallback((theme: Theme) => { + onThemeUpdateRef.current?.(theme); + }, []); + + /** + * Handle errors + */ + const handleError = useCallback((error: string) => { + onErrorRef.current?.(error); + }, []); + + // Initialize WebSocket with handlers + const ws = useWebSocket({ + ...wsOptions, + handlers: { + onSessionsUpdate: handleSessionsUpdate, + onSessionStateChange: handleSessionStateChange, + onSessionAdded: handleSessionAdded, + onSessionRemoved: handleSessionRemoved, + onThemeUpdate: handleThemeUpdate, + onError: handleError, + }, + }); + + // Auto-connect on mount if enabled + useEffect(() => { + if (autoConnect && ws.state === 'disconnected') { + ws.connect(); + } + }, [autoConnect, ws.state, ws.connect]); + + /** + * Get the active session object + */ + const activeSession = useMemo(() => { + if (!activeSessionId) return null; + return sessions.find((s) => s.id === activeSessionId) ?? null; + }, [sessions, activeSessionId]); + + /** + * Notify when active session changes + */ + useEffect(() => { + onActiveSessionChangeRef.current?.(activeSession); + }, [activeSession]); + + /** + * Set the active session by ID + */ + const setActiveSessionId = useCallback((sessionId: string | null) => { + setActiveSessionIdState(sessionId); + }, []); + + /** + * Get a session by ID + */ + const getSession = useCallback( + (sessionId: string): Session | undefined => { + return sessions.find((s) => s.id === sessionId); + }, + [sessions] + ); + + /** + * Sessions organized by group (tool type as a simple grouping) + */ + const sessionsByGroup = useMemo(() => { + const groups: Record = {}; + for (const session of sessions) { + const group = session.toolType || 'other'; + if (!groups[group]) { + groups[group] = []; + } + groups[group].push(session); + } + return groups; + }, [sessions]); + + /** + * Get the base URL for API requests + */ + const getApiBaseUrl = useCallback((): string => { + return `${window.location.protocol}//${window.location.host}`; + }, []); + + /** + * Send a command to a session + */ + const sendCommand = useCallback( + async (sessionId: string, command: string): Promise => { + // Mark session as sending + setSessions((prev) => { + const index = prev.findIndex((s) => s.id === sessionId); + if (index === -1) return prev; + const updated = [...prev]; + updated[index] = { ...updated[index], isSending: true, lastError: undefined }; + return updated; + }); + + try { + const response = await fetch(`${getApiBaseUrl()}/api/session/${sessionId}/send`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ command }), + }); + + const result = await response.json(); + + if (!response.ok || !result.success) { + throw new Error(result.error || 'Failed to send command'); + } + + // Clear sending state on success + setSessions((prev) => { + const index = prev.findIndex((s) => s.id === sessionId); + if (index === -1) return prev; + const updated = [...prev]; + updated[index] = { ...updated[index], isSending: false }; + return updated; + }); + + return true; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + // Set error state + setSessions((prev) => { + const index = prev.findIndex((s) => s.id === sessionId); + if (index === -1) return prev; + const updated = [...prev]; + updated[index] = { ...updated[index], isSending: false, lastError: errorMessage }; + return updated; + }); + + onErrorRef.current?.(errorMessage); + return false; + } + }, + [getApiBaseUrl] + ); + + /** + * Send a command to the active session + */ + const sendToActive = useCallback( + async (command: string): Promise => { + if (!activeSessionId) { + onErrorRef.current?.('No active session'); + return false; + } + return sendCommand(activeSessionId, command); + }, + [activeSessionId, sendCommand] + ); + + /** + * Interrupt a session + */ + const interrupt = useCallback( + async (sessionId: string): Promise => { + try { + const response = await fetch(`${getApiBaseUrl()}/api/session/${sessionId}/interrupt`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const result = await response.json(); + + if (!response.ok || !result.success) { + throw new Error(result.error || 'Failed to interrupt session'); + } + + return true; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + onErrorRef.current?.(errorMessage); + return false; + } + }, + [getApiBaseUrl] + ); + + /** + * Interrupt the active session + */ + const interruptActive = useCallback(async (): Promise => { + if (!activeSessionId) { + onErrorRef.current?.('No active session'); + return false; + } + return interrupt(activeSessionId); + }, [activeSessionId, interrupt]); + + /** + * Switch session mode (AI/Terminal) + */ + const switchMode = useCallback( + async (sessionId: string, mode: InputMode): Promise => { + // This would typically be sent via WebSocket or API + // For now, we send it as a message via WebSocket + return ws.send({ + type: 'switch_mode', + sessionId, + mode, + }); + }, + [ws] + ); + + /** + * Refresh the sessions list + */ + const refreshSessions = useCallback(() => { + ws.send({ type: 'get_sessions' }); + }, [ws]); + + return { + // Session data + sessions, + sessionsByGroup, + activeSession, + setActiveSessionId, + getSession, + + // Connection state + connectionState: ws.state, + isConnected: ws.isAuthenticated, + connectionError: ws.error, + clientId: ws.clientId, + + // Connection methods + connect: ws.connect, + disconnect: ws.disconnect, + authenticate: ws.authenticate, + + // Session interaction methods + sendCommand, + sendToActive, + interrupt, + interruptActive, + switchMode, + refreshSessions, + + // Underlying WebSocket hook + ws, + }; +} + +export default useSessions;