From c91502156ac63e83ee5b423e1de6be37b6d96546 Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Sun, 7 Sep 2025 10:03:20 -0400 Subject: [PATCH 01/16] Refactor WebSocket message types and models Moved WebSocket-related types (StreamMessage, StreamingPlanUpdate, etc.) from the service to the models directory for better separation of concerns. Introduced a WebsocketMessageType enum for consistent message type handling. Updated imports and usages across components, hooks, and services to use the new model definitions. Improved message handling logic in WebSocketService for clarity and maintainability. --- .../src/components/content/PlanPanelRight.tsx | 5 +- src/frontend/src/hooks/useWebSocket.tsx | 71 ++-- src/frontend/src/models/enums.tsx | 16 + src/frontend/src/models/index.tsx | 6 - src/frontend/src/models/messages.tsx | 56 ++- src/frontend/src/models/plan.tsx | 2 +- src/frontend/src/pages/PlanPage.tsx | 4 +- .../src/services/WebSocketService.tsx | 370 +++++------------- src/frontend/src/services/index.tsx | 1 + 9 files changed, 214 insertions(+), 317 deletions(-) diff --git a/src/frontend/src/components/content/PlanPanelRight.tsx b/src/frontend/src/components/content/PlanPanelRight.tsx index 21aac358a..1a1290eb5 100644 --- a/src/frontend/src/components/content/PlanPanelRight.tsx +++ b/src/frontend/src/components/content/PlanPanelRight.tsx @@ -12,11 +12,10 @@ import { ClockRegular, PersonRegular, } from "@fluentui/react-icons"; -import { MPlanData } from "../../models"; +import { MPlanData, StreamingPlanUpdate } from "../../models"; import { TaskService } from "../../services/TaskService"; -import { Step } from "../../models/plan"; import { PlanDataService } from "../../services/PlanDataService"; -import webSocketService, { StreamingPlanUpdate } from "../../services/WebSocketService"; +import webSocketService from "../../services/WebSocketService"; import ContentNotFound from "../NotFound/ContentNotFound"; // Clean interface - only display-related props diff --git a/src/frontend/src/hooks/useWebSocket.tsx b/src/frontend/src/hooks/useWebSocket.tsx index 6984e784d..349eb6b98 100644 --- a/src/frontend/src/hooks/useWebSocket.tsx +++ b/src/frontend/src/hooks/useWebSocket.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { webSocketService, StreamMessage } from '../services/WebSocketService'; +import { webSocketService } from '@/services'; +import { StreamMessage } from '../models'; export interface WebSocketState { isConnected: boolean; @@ -15,7 +16,7 @@ export const useWebSocket = () => { isReconnecting: false, error: null }); - + const isConnectedRef = useRef(false); const isConnectingRef = useRef(false); const lastSessionIdRef = useRef(null); @@ -32,54 +33,54 @@ export const useWebSocket = () => { const connectWebSocket = useCallback(async (sessionId: string, processId?: string) => { if (isConnectedRef.current || isConnectingRef.current) return; - + setIsConnecting(true); lastSessionIdRef.current = sessionId; lastProcessIdRef.current = processId; - + try { await webSocketService.connect(sessionId, processId); isConnectedRef.current = true; - setState(prev => ({ - ...prev, - isConnected: true, - isConnecting: false, - error: null + setState(prev => ({ + ...prev, + isConnected: true, + isConnecting: false, + error: null })); } catch (error) { console.error('Failed to connect to WebSocket:', error); isConnectedRef.current = false; isConnectingRef.current = false; - setState(prev => ({ - ...prev, - isConnected: false, - isConnecting: false, - error: 'Failed to connect to server' + setState(prev => ({ + ...prev, + isConnected: false, + isConnecting: false, + error: 'Failed to connect to server' })); } }, [setIsConnecting]); const reconnect = useCallback(async () => { if (!lastSessionIdRef.current) return; - + setIsReconnecting(true); try { await webSocketService.connect(lastSessionIdRef.current, lastProcessIdRef.current); isConnectedRef.current = true; - setState(prev => ({ - ...prev, - isConnected: true, - isReconnecting: false, - error: null + setState(prev => ({ + ...prev, + isConnected: true, + isReconnecting: false, + error: null })); } catch (error) { console.error('Failed to reconnect to WebSocket:', error); isConnectedRef.current = false; - setState(prev => ({ - ...prev, - isConnected: false, - isReconnecting: false, - error: 'Failed to reconnect to server' + setState(prev => ({ + ...prev, + isConnected: false, + isReconnecting: false, + error: 'Failed to reconnect to server' })); } }, [setIsReconnecting]); @@ -88,11 +89,11 @@ export const useWebSocket = () => { webSocketService.disconnect(); isConnectedRef.current = false; isConnectingRef.current = false; - setState(prev => ({ - ...prev, - isConnected: false, - isConnecting: false, - isReconnecting: false + setState(prev => ({ + ...prev, + isConnected: false, + isConnecting: false, + isReconnecting: false })); }, []); @@ -102,8 +103,8 @@ export const useWebSocket = () => { if (message.data?.connected !== undefined) { const connected = message.data.connected; isConnectedRef.current = connected; - setState(prev => ({ - ...prev, + setState(prev => ({ + ...prev, isConnected: connected, isConnecting: false, isReconnecting: false, @@ -115,10 +116,10 @@ export const useWebSocket = () => { // Set up error listener const unsubscribeError = webSocketService.on('error', (message: StreamMessage) => { isConnectedRef.current = false; - setState(prev => ({ - ...prev, + setState(prev => ({ + ...prev, isConnected: false, - error: message.data?.error || 'WebSocket error occurred' + error: message.data?.error || 'WebSocket error occurred' })); }); diff --git a/src/frontend/src/models/enums.tsx b/src/frontend/src/models/enums.tsx index a20e30184..4157b4902 100644 --- a/src/frontend/src/models/enums.tsx +++ b/src/frontend/src/models/enums.tsx @@ -235,3 +235,19 @@ export enum HumanFeedbackStatus { ACCEPTED = "accepted", REJECTED = "rejected" } + +export enum WebsocketMessageType { + SYSTEM_MESSAGE = "system_message", + AGENT_MESSAGE = "agent_message", + AGENT_STREAM_START = "agent_stream_start", + AGENT_STREAM_END = "agent_stream_end", + AGENT_MESSAGE_STREAMING = "agent_message_streaming", + AGENT_TOOL_MESSAGE = "agent_tool_message", + PLAN_APPROVAL_REQUEST = "plan_approval_request", + PLAN_APPROVAL_RESPONSE = "plan_approval_response", + REPLAN_APPROVAL_REQUEST = "replan_approval_request", + REPLAN_APPROVAL_RESPONSE = "replan_approval_response", + USER_CLARIFICATION_REQUEST = "user_clarification_request", + USER_CLARIFICATION_RESPONSE = "user_clarification_response", + FINAL_RESULT_MESSAGE = "final_result_message" +} \ No newline at end of file diff --git a/src/frontend/src/models/index.tsx b/src/frontend/src/models/index.tsx index 3ba2c1468..74fe486ec 100644 --- a/src/frontend/src/models/index.tsx +++ b/src/frontend/src/models/index.tsx @@ -22,11 +22,5 @@ export type { Agent as TaskAgent } from './taskDetails'; // Export Team models (Agent interface takes precedence) export * from './Team'; -// Export WebSocket service types that are needed by components -export type { - StreamingPlanUpdate, - StreamMessage, - ParsedPlanApprovalRequest -} from '../services/WebSocketService'; // Add other model exports as needed \ No newline at end of file diff --git a/src/frontend/src/models/messages.tsx b/src/frontend/src/models/messages.tsx index 5b54e1a1d..b3c96ca48 100644 --- a/src/frontend/src/models/messages.tsx +++ b/src/frontend/src/models/messages.tsx @@ -1,4 +1,5 @@ -import { AgentType, StepStatus, PlanStatus } from './enums'; +import { AgentType, StepStatus, PlanStatus, WebsocketMessageType } from './enums'; +import { MPlanData } from './plan'; /** * Message roles compatible with Semantic Kernel @@ -114,4 +115,57 @@ export interface PlanStateUpdate { session_id: string; /** Overall status of the plan */ overall_status: PlanStatus; +} + + + +export interface StreamMessage { + type: WebsocketMessageType + plan_id?: string; + session_id?: string; + data?: any; + timestamp?: string | number; +} + +export interface StreamingPlanUpdate { + plan_id: string; + session_id?: string; + step_id?: string; + agent_name?: string; + content?: string; + status?: 'in_progress' | 'completed' | 'error' | 'creating_plan' | 'pending_approval'; + message_type?: 'thinking' | 'action' | 'result' | 'clarification_needed' | 'plan_approval_request'; + timestamp?: number; + is_final?: boolean; +} + +export interface PlanApprovalRequestData { + plan_id: string; + session_id: string; + plan: { + steps: Array<{ + id: string; + description: string; + agent: string; + estimated_duration?: string; + }>; + total_steps: number; + estimated_completion?: string; + }; + status: 'PENDING_APPROVAL'; +} + +export interface PlanApprovalResponseData { + plan_id: string; + session_id: string; + approved: boolean; + feedback?: string; +} + +// Structured plan approval request +export interface ParsedPlanApprovalRequest { + type: 'parsed_plan_approval_request'; + plan_id: string; + parsedData: MPlanData; + rawData: string; } \ No newline at end of file diff --git a/src/frontend/src/models/plan.tsx b/src/frontend/src/models/plan.tsx index d8a511abb..91b5991c8 100644 --- a/src/frontend/src/models/plan.tsx +++ b/src/frontend/src/models/plan.tsx @@ -1,5 +1,5 @@ import { AgentType, PlanStatus, StepStatus, HumanFeedbackStatus } from './enums'; -import { StreamingPlanUpdate } from '../services/WebSocketService'; +import { StreamingPlanUpdate } from './messages'; /** * Base interface with common fields diff --git a/src/frontend/src/pages/PlanPage.tsx b/src/frontend/src/pages/PlanPage.tsx index 4f53f7a01..9f93768f1 100644 --- a/src/frontend/src/pages/PlanPage.tsx +++ b/src/frontend/src/pages/PlanPage.tsx @@ -20,9 +20,9 @@ import LoadingMessage, { loadingMessages } from "../coral/components/LoadingMess import { RAIErrorCard, RAIErrorData } from "../components/errors"; import { TeamConfig } from "../models/Team"; import { TeamService } from "../services/TeamService"; -import webSocketService, { StreamMessage, StreamingPlanUpdate } from "../services/WebSocketService"; +import webSocketService from "../services/WebSocketService"; import { APIService } from "../api/apiService"; -import { Step } from "../models/plan"; +import { StreamMessage, StreamingPlanUpdate } from "../models"; import "../styles/PlanPage.css" diff --git a/src/frontend/src/services/WebSocketService.tsx b/src/frontend/src/services/WebSocketService.tsx index 92a42adf9..fbaadfdcc 100644 --- a/src/frontend/src/services/WebSocketService.tsx +++ b/src/frontend/src/services/WebSocketService.tsx @@ -1,57 +1,7 @@ import { headerBuilder } from '../api/config'; import { PlanDataService } from './PlanDataService'; -import { MPlanData } from '../models'; +import { MPlanData, ParsedPlanApprovalRequest, StreamingPlanUpdate, StreamMessage, WebsocketMessageType } from '../models'; -export interface StreamMessage { - type: 'plan_update' | 'step_update' | 'agent_message' | 'error' | 'connection_status' | 'plan_approval_request' | 'final_result' | 'parsed_plan_approval_request' | 'streaming_message'; - plan_id?: string; - session_id?: string; - data?: any; - timestamp?: string; -} - -export interface StreamingPlanUpdate { - plan_id: string; - session_id?: string; - step_id?: string; - agent_name?: string; - content?: string; - status?: 'in_progress' | 'completed' | 'error' | 'creating_plan' | 'pending_approval'; - message_type?: 'thinking' | 'action' | 'result' | 'clarification_needed' | 'plan_approval_request'; - timestamp?: number; - is_final?: boolean; -} - -export interface PlanApprovalRequestData { - plan_id: string; - session_id: string; - plan: { - steps: Array<{ - id: string; - description: string; - agent: string; - estimated_duration?: string; - }>; - total_steps: number; - estimated_completion?: string; - }; - status: 'PENDING_APPROVAL'; -} - -export interface PlanApprovalResponseData { - plan_id: string; - session_id: string; - approved: boolean; - feedback?: string; -} - -// New interface for structured plan approval request -export interface ParsedPlanApprovalRequest { - type: 'parsed_plan_approval_request'; - plan_id: string; - parsedData: MPlanData; - rawData: string; -} class WebSocketService { private ws: WebSocket | null = null; @@ -64,44 +14,30 @@ class WebSocketService { private isConnecting = false; private baseWsUrl = process.env.REACT_APP_BACKEND_URL?.replace('http', 'ws') || 'ws://localhost:8000'; - /** - * Connect to WebSocket server - */ connect(sessionId: string, processId?: string): Promise { return new Promise((resolve, reject) => { if (this.isConnecting) { - console.log('Connection already in progress'); reject(new Error('Connection already in progress')); return; } - if (this.ws?.readyState === WebSocket.OPEN) { - console.log('WebSocket already connected'); resolve(); return; } - try { this.isConnecting = true; - - // Use v3 WebSocket endpoint format const wsUrl = processId ? `${this.baseWsUrl}/api/v3/socket/${processId}` : `${this.baseWsUrl}/api/v3/socket/${sessionId}`; - - console.log('Connecting to WebSocket:', wsUrl); this.ws = new WebSocket(wsUrl); - this.ws.onopen = (event) => { - console.log('WebSocket connected successfully'); + this.ws.onopen = () => { this.isConnecting = false; this.reconnectAttempts = 0; - if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } - this.emit('connection_status', { connected: true }); resolve(); }; @@ -116,48 +52,34 @@ class WebSocketService { }; this.ws.onclose = (event) => { - console.log(`WebSocket disconnected: ${event.code} - ${event.reason}`); this.isConnecting = false; this.ws = null; this.emit('connection_status', { connected: false }); - if (this.reconnectAttempts < this.maxReconnectAttempts && event.code !== 1000) { this.attemptReconnect(); } }; - this.ws.onerror = (event) => { - console.error('WebSocket error:', event); + this.ws.onerror = () => { this.isConnecting = false; - if (this.reconnectAttempts === 0) { reject(new Error('WebSocket connection failed')); } - this.emit('error', { error: 'WebSocket connection error' }); }; - } catch (error) { this.isConnecting = false; - console.error('Failed to create WebSocket connection:', error); reject(error); } }); } - /** - * Disconnect from WebSocket server - */ disconnect(): void { - console.log('Manually disconnecting WebSocket'); - if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } - this.reconnectAttempts = this.maxReconnectAttempts; - if (this.ws) { this.ws.close(1000, 'Manual disconnect'); this.ws = null; @@ -166,276 +88,195 @@ class WebSocketService { this.isConnecting = false; } - /** - * Subscribe to plan updates - */ subscribeToPlan(planId: string): void { if (this.ws && this.ws.readyState === WebSocket.OPEN) { - const message = { - type: 'subscribe_plan', - plan_id: planId - }; - + const message = { type: 'subscribe_plan', plan_id: planId }; this.ws.send(JSON.stringify(message)); this.planSubscriptions.add(planId); - console.log(`Subscribed to plan updates: ${planId}`); } } - /** - * Unsubscribe from plan updates - */ unsubscribeFromPlan(planId: string): void { if (this.ws && this.ws.readyState === WebSocket.OPEN) { - const message = { - type: 'unsubscribe_plan', - plan_id: planId - }; - + const message = { type: 'unsubscribe_plan', plan_id: planId }; this.ws.send(JSON.stringify(message)); this.planSubscriptions.delete(planId); - console.log(`Unsubscribed from plan updates: ${planId}`); } } - /** - * Add event listener - */ on(eventType: string, callback: (message: StreamMessage) => void): () => void { if (!this.listeners.has(eventType)) { this.listeners.set(eventType, new Set()); } - this.listeners.get(eventType)!.add(callback); - return () => { - const eventListeners = this.listeners.get(eventType); - if (eventListeners) { - eventListeners.delete(callback); - if (eventListeners.size === 0) { - this.listeners.delete(eventType); - } + const setRef = this.listeners.get(eventType); + if (setRef) { + setRef.delete(callback); + if (setRef.size === 0) this.listeners.delete(eventType); } }; } - /** - * Remove event listener - */ off(eventType: string, callback: (message: StreamMessage) => void): void { - const eventListeners = this.listeners.get(eventType); - if (eventListeners) { - eventListeners.delete(callback); - if (eventListeners.size === 0) { - this.listeners.delete(eventType); - } + const setRef = this.listeners.get(eventType); + if (setRef) { + setRef.delete(callback); + if (setRef.size === 0) this.listeners.delete(eventType); } } - /** - * Connection change event handler - */ onConnectionChange(callback: (connected: boolean) => void): () => void { return this.on('connection_status', (message: StreamMessage) => { callback(message.data?.connected || false); }); } - /** - * Streaming message event handler - */ onStreamingMessage(callback: (message: StreamingPlanUpdate) => void): () => void { - return this.on('agent_message', (message: StreamMessage) => { - if (message.data) { - callback(message.data); - } + return this.on(WebsocketMessageType.AGENT_MESSAGE, (message: StreamMessage) => { + if (message.data) callback(message.data); }); } - /** - * Plan approval request event handler - */ onPlanApprovalRequest(callback: (approvalRequest: ParsedPlanApprovalRequest) => void): () => void { return this.on('parsed_plan_approval_request', (message: StreamMessage) => { - if (message.data) { - callback(message.data); - } + if (message.data) callback(message.data); }); } - /** - * Plan approval response event handler - */ onPlanApprovalResponse(callback: (response: any) => void): () => void { - return this.on('plan_approval_response', (message: StreamMessage) => { - if (message.data) { - callback(message.data); - } + return this.on(WebsocketMessageType.PLAN_APPROVAL_RESPONSE, (message: StreamMessage) => { + if (message.data) callback(message.data); }); } - /** - * Emit event to listeners - */ private emit(eventType: string, data: any): void { const message: StreamMessage = { type: eventType as any, data, timestamp: new Date().toISOString() }; - - const eventListeners = this.listeners.get(eventType); - if (eventListeners) { - eventListeners.forEach(callback => { - try { - callback(message); - } catch (error) { - console.error('Error in WebSocket event listener:', error); - } + const setRef = this.listeners.get(eventType); + if (setRef) { + setRef.forEach(cb => { + try { cb(message); } catch (e) { console.error('Listener error:', e); } }); } } - /** - * Handle incoming WebSocket messages - */ private handleMessage(message: StreamMessage): void { console.log('WebSocket message received:', message); - - if (message.type === 'plan_approval_request') { - console.log('Plan approval request received via WebSocket:', message.data); - - // Parse the raw Python object string using PlanDataService - const parsedData = PlanDataService.parsePlanApprovalRequest(message.data); - - if (parsedData) { - // Emit a structured plan approval request - const structuredMessage: ParsedPlanApprovalRequest = { - type: 'parsed_plan_approval_request', - plan_id: parsedData.id, - parsedData: parsedData, - rawData: message.data - }; - - this.emit('parsed_plan_approval_request', structuredMessage); - console.log('Parsed plan approval request:', structuredMessage); - } else { - console.error('Failed to parse plan approval request data'); - this.emit('error', { error: 'Failed to parse plan approval request' }); + const currentPlanIds = Array.from(this.planSubscriptions); + const firstPlanId = currentPlanIds[0]; + + switch (message.type) { + case WebsocketMessageType.PLAN_APPROVAL_REQUEST: { + const parsedData = PlanDataService.parsePlanApprovalRequest(message.data); + if (parsedData) { + const structuredMessage: ParsedPlanApprovalRequest = { + type: 'parsed_plan_approval_request', + plan_id: parsedData.id, + parsedData, + rawData: message.data + }; + this.emit('parsed_plan_approval_request', structuredMessage); + } else { + this.emit('error', { error: 'Failed to parse plan approval request' }); + } + break; } - } - // Handle agent messages from the callback system (without plan_id) - else if (message.type === 'agent_message' && message.data && !message.data.plan_id) { - console.log('Agent callback message received:', message.data); - - // Transform the callback message format to match the expected streaming format - // We'll need to get the current plan_id from somewhere - let's use the current subscription - const currentPlanIds = Array.from(this.planSubscriptions); - - if (currentPlanIds.length > 0) { - const transformedMessage: StreamMessage = { - ...message, - data: { - plan_id: currentPlanIds[0], // Use the first subscribed plan - agent_name: message.data.agent_name || 'Unknown Agent', - content: message.data.content || '', - message_type: 'thinking', - status: 'in_progress', - timestamp: Date.now() / 1000 - } - }; - - console.log('Transformed agent message for plan:', transformedMessage.data.plan_id); - this.emit(message.type, transformedMessage); - } else { - console.warn('Received agent message but no plan subscriptions active'); + case WebsocketMessageType.AGENT_MESSAGE: { + if (message.data && !message.data.plan_id && firstPlanId) { + const transformed: StreamMessage = { + ...message, + data: { + plan_id: firstPlanId, + agent_name: message.data.agent_name || 'Unknown Agent', + content: message.data.content || '', + message_type: 'thinking', + status: 'in_progress', + timestamp: Date.now() / 1000 + } + }; + this.emit(WebsocketMessageType.AGENT_MESSAGE, transformed); + } else { + this.emit(WebsocketMessageType.AGENT_MESSAGE, message); + } + break; } - } - - // Handle streaming messages from the callback system - else if (message.type === 'streaming_message' && message.data && !message.data.plan_id) { - console.log('Streaming callback message received:', message.data); - // Transform streaming message format - const currentPlanIds = Array.from(this.planSubscriptions); - - if (currentPlanIds.length > 0) { - const transformedMessage: StreamMessage = { - type: 'agent_message', // Convert streaming_message to agent_message - data: { - plan_id: currentPlanIds[0], - agent_name: message.data.agent_name || 'Unknown Agent', - content: message.data.content || '', - message_type: message.data.is_final ? 'result' : 'thinking', - status: message.data.is_final ? 'completed' : 'in_progress', - timestamp: Date.now() / 1000 + case WebsocketMessageType.AGENT_MESSAGE_STREAMING: { + if (message.data) { + const isFinal = !!message.data.is_final; + const transformed: StreamMessage = { + type: WebsocketMessageType.AGENT_MESSAGE, + data: { + plan_id: message.data.plan_id || firstPlanId, + agent_name: message.data.agent_name || 'Unknown Agent', + content: message.data.content || '', + message_type: isFinal ? 'result' : 'thinking', + status: isFinal ? 'completed' : 'in_progress', + timestamp: Date.now() / 1000, + is_final: isFinal + } + }; + if (!transformed.data.plan_id) { + console.warn('Streaming message missing plan_id and no subscription context.'); + break; } - }; + this.emit(WebsocketMessageType.AGENT_MESSAGE, transformed); + } + break; + } - console.log('Transformed streaming message for plan:', transformedMessage.data.plan_id); - this.emit('agent_message', transformedMessage); + case WebsocketMessageType.AGENT_TOOL_MESSAGE: + case WebsocketMessageType.USER_CLARIFICATION_REQUEST: + case WebsocketMessageType.USER_CLARIFICATION_RESPONSE: + case WebsocketMessageType.REPLAN_APPROVAL_REQUEST: + case WebsocketMessageType.REPLAN_APPROVAL_RESPONSE: + case WebsocketMessageType.PLAN_APPROVAL_RESPONSE: + case WebsocketMessageType.FINAL_RESULT_MESSAGE: + case WebsocketMessageType.AGENT_STREAM_START: + case WebsocketMessageType.AGENT_STREAM_END: + case WebsocketMessageType.SYSTEM_MESSAGE: { + this.emit(message.type, message); + break; } - } - // Handle regular streaming messages (already have plan_id) - else { - // Emit the message as-is for other types or when plan_id is present - this.emit(message.type, message); + default: { + this.emit(message.type, message); + break; + } } } - /** - * Attempt to reconnect with exponential backoff - */ private attemptReconnect(): void { if (this.reconnectAttempts >= this.maxReconnectAttempts) { - console.log('Max reconnection attempts reached - stopping reconnect attempts'); this.emit('error', { error: 'Max reconnection attempts reached' }); return; } - - if (this.isConnecting || this.reconnectTimer) { - console.log('Reconnection attempt already in progress'); - return; - } - + if (this.isConnecting || this.reconnectTimer) return; this.reconnectAttempts++; const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); - - console.log(`Scheduling reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay / 1000}s`); - this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; - console.log(`Attempting reconnection (attempt ${this.reconnectAttempts})`); - - // We need to store the original session/process IDs for reconnection - // For now, we'll emit an error and let the parent component handle reconnection this.emit('error', { error: 'Connection lost - manual reconnection required' }); }, delay); } - /** - * Get connection status - */ isConnected(): boolean { return this.ws?.readyState === WebSocket.OPEN; } - /** - * Send message to server - */ send(message: any): void { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(message)); } else { - console.warn('WebSocket is not connected. Cannot send message:', message); + console.warn('WebSocket not connected. Cannot send:', message); } } - /** - * Send plan approval response for v3 backend - */ sendPlanApprovalResponse(response: { plan_id: string; session_id: string; @@ -445,30 +286,21 @@ class WebSocketService { human_clarification?: string; }): void { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - console.error('WebSocket not connected - cannot send plan approval response'); this.emit('error', { error: 'Cannot send plan approval response - WebSocket not connected' }); return; } - try { - // Send in v3 expected format const v3Response = { - m_plan_id: response.plan_id, // v3 backend expects 'm_plan_id' + m_plan_id: response.plan_id, approved: response.approved, feedback: response.feedback || response.user_response || response.human_clarification || '', }; - - console.log('📤 Sending v3 plan approval response:', v3Response); - const message = { - type: 'plan_approval_response', + type: WebsocketMessageType.PLAN_APPROVAL_RESPONSE, data: v3Response }; - this.ws.send(JSON.stringify(message)); - console.log('Plan approval response sent successfully'); - } catch (error) { - console.error('Failed to send plan approval response:', error); + } catch { this.emit('error', { error: 'Failed to send plan approval response' }); } } diff --git a/src/frontend/src/services/index.tsx b/src/frontend/src/services/index.tsx index 9619462ad..2084ee9b7 100644 --- a/src/frontend/src/services/index.tsx +++ b/src/frontend/src/services/index.tsx @@ -1,2 +1,3 @@ export { default as TaskService } from './TaskService'; +export * from './WebSocketService'; From 91a9aa83e204e5fb068bc448fc83b920a8b8c803 Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Sun, 7 Sep 2025 10:14:12 -0400 Subject: [PATCH 02/16] Refactor plan approval request event to use enum Replaced hardcoded 'parsed_plan_approval_request' string with WebsocketMessageType.PLAN_APPROVAL_REQUEST enum across components, models, and services for improved type safety and maintainability. --- src/frontend/src/components/content/PlanChat.tsx | 3 ++- src/frontend/src/models/messages.tsx | 2 +- src/frontend/src/pages/PlanPage.tsx | 4 ++-- src/frontend/src/services/PlanDataService.tsx | 5 +++-- src/frontend/src/services/WebSocketService.tsx | 6 +++--- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/frontend/src/components/content/PlanChat.tsx b/src/frontend/src/components/content/PlanChat.tsx index 85e495f12..787310787 100644 --- a/src/frontend/src/components/content/PlanChat.tsx +++ b/src/frontend/src/components/content/PlanChat.tsx @@ -24,6 +24,7 @@ import ChatInput from "../../coral/modules/ChatInput"; import InlineToaster, { useInlineToaster, } from "../toast/InlineToaster"; +import { WebsocketMessageType } from "@/models"; interface SimplifiedPlanChatProps extends PlanChatProps { onPlanReceived?: (planData: MPlanData) => void; initialTask?: string; @@ -60,7 +61,7 @@ const PlanChat: React.FC = ({ // Listen for m_plan streaming useEffect(() => { - const unsubscribe = webSocketService.on('parsed_plan_approval_request', (approvalRequest: any) => { + const unsubscribe = webSocketService.on(WebsocketMessageType.PLAN_APPROVAL_REQUEST, (approvalRequest: any) => { console.log('📋 Plan received:', approvalRequest); let mPlanData: MPlanData | null = null; diff --git a/src/frontend/src/models/messages.tsx b/src/frontend/src/models/messages.tsx index b3c96ca48..633e38a1e 100644 --- a/src/frontend/src/models/messages.tsx +++ b/src/frontend/src/models/messages.tsx @@ -164,7 +164,7 @@ export interface PlanApprovalResponseData { // Structured plan approval request export interface ParsedPlanApprovalRequest { - type: 'parsed_plan_approval_request'; + type: WebsocketMessageType.PLAN_APPROVAL_REQUEST; plan_id: string; parsedData: MPlanData; rawData: string; diff --git a/src/frontend/src/pages/PlanPage.tsx b/src/frontend/src/pages/PlanPage.tsx index 9f93768f1..907e2e7d5 100644 --- a/src/frontend/src/pages/PlanPage.tsx +++ b/src/frontend/src/pages/PlanPage.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState, useMemo } from "react" import { useParams, useNavigate } from "react-router-dom"; import { Spinner, Text } from "@fluentui/react-components"; import { PlanDataService } from "../services/PlanDataService"; -import { ProcessedPlanData, PlanWithSteps } from "../models"; +import { ProcessedPlanData, PlanWithSteps, WebsocketMessageType } from "../models"; import PlanChat from "../components/content/PlanChat"; import PlanPanelRight from "../components/content/PlanPanelRight"; import PlanPanelLeft from "../components/content/PlanPanelLeft"; @@ -131,7 +131,7 @@ const PlanPage: React.FC = () => { const unsubscribeStreaming = webSocketService.on('agent_message', handleStreamingMessage); const unsubscribePlanApproval = webSocketService.on('plan_approval_response', handlePlanApprovalResponse); const unsubscribePlanApprovalRequest = webSocketService.on('plan_approval_request', handlePlanApprovalRequest); - const unsubscribeParsedPlanApprovalRequest = webSocketService.on('parsed_plan_approval_request', handlePlanApprovalRequest); + const unsubscribeParsedPlanApprovalRequest = webSocketService.on(WebsocketMessageType.PLAN_APPROVAL_REQUEST, handlePlanApprovalRequest); return () => { console.log('🔌 Cleaning up WebSocket connections'); diff --git a/src/frontend/src/services/PlanDataService.tsx b/src/frontend/src/services/PlanDataService.tsx index e11d61d07..a34da50f0 100644 --- a/src/frontend/src/services/PlanDataService.tsx +++ b/src/frontend/src/services/PlanDataService.tsx @@ -5,7 +5,8 @@ import { ProcessedPlanData, PlanMessage, MPlanData, - StepStatus + StepStatus, + WebsocketMessageType } from "@/models"; import { apiService } from "@/api"; @@ -156,7 +157,7 @@ export class PlanDataService { console.log('🔍 Parsing plan approval request:', rawData, 'Type:', typeof rawData); // Already parsed object passthrough - if (rawData && typeof rawData === 'object' && rawData.type === 'parsed_plan_approval_request') { + if (rawData && typeof rawData === 'object' && rawData.type === WebsocketMessageType.PLAN_APPROVAL_REQUEST) { return rawData.parsedData || null; } diff --git a/src/frontend/src/services/WebSocketService.tsx b/src/frontend/src/services/WebSocketService.tsx index fbaadfdcc..423791e46 100644 --- a/src/frontend/src/services/WebSocketService.tsx +++ b/src/frontend/src/services/WebSocketService.tsx @@ -139,7 +139,7 @@ class WebSocketService { } onPlanApprovalRequest(callback: (approvalRequest: ParsedPlanApprovalRequest) => void): () => void { - return this.on('parsed_plan_approval_request', (message: StreamMessage) => { + return this.on(WebsocketMessageType.PLAN_APPROVAL_REQUEST, (message: StreamMessage) => { if (message.data) callback(message.data); }); } @@ -174,12 +174,12 @@ class WebSocketService { const parsedData = PlanDataService.parsePlanApprovalRequest(message.data); if (parsedData) { const structuredMessage: ParsedPlanApprovalRequest = { - type: 'parsed_plan_approval_request', + type: WebsocketMessageType.PLAN_APPROVAL_REQUEST, plan_id: parsedData.id, parsedData, rawData: message.data }; - this.emit('parsed_plan_approval_request', structuredMessage); + this.emit(WebsocketMessageType.PLAN_APPROVAL_REQUEST, structuredMessage); } else { this.emit('error', { error: 'Failed to parse plan approval request' }); } From 86f6a8f51cf279f2556ed89d0021cadd6f09ad78 Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Sun, 7 Sep 2025 10:18:53 -0400 Subject: [PATCH 03/16] Refactor websocket message type usage Replaced string literals with WebsocketMessageType enum for agent messages and plan approval events in PlanPage and PlanDataService. This improves type safety and consistency when handling websocket events. --- src/frontend/src/pages/PlanPage.tsx | 6 +++--- src/frontend/src/services/PlanDataService.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/pages/PlanPage.tsx b/src/frontend/src/pages/PlanPage.tsx index 907e2e7d5..550abf945 100644 --- a/src/frontend/src/pages/PlanPage.tsx +++ b/src/frontend/src/pages/PlanPage.tsx @@ -128,9 +128,9 @@ const PlanPage: React.FC = () => { handleConnectionChange(message.data?.connected || false); }); - const unsubscribeStreaming = webSocketService.on('agent_message', handleStreamingMessage); - const unsubscribePlanApproval = webSocketService.on('plan_approval_response', handlePlanApprovalResponse); - const unsubscribePlanApprovalRequest = webSocketService.on('plan_approval_request', handlePlanApprovalRequest); + const unsubscribeStreaming = webSocketService.on(WebsocketMessageType.AGENT_MESSAGE, handleStreamingMessage); + const unsubscribePlanApproval = webSocketService.on(WebsocketMessageType.PLAN_APPROVAL_RESPONSE, handlePlanApprovalResponse); + const unsubscribePlanApprovalRequest = webSocketService.on(WebsocketMessageType.PLAN_APPROVAL_REQUEST, handlePlanApprovalRequest); const unsubscribeParsedPlanApprovalRequest = webSocketService.on(WebsocketMessageType.PLAN_APPROVAL_REQUEST, handlePlanApprovalRequest); return () => { diff --git a/src/frontend/src/services/PlanDataService.tsx b/src/frontend/src/services/PlanDataService.tsx index a34da50f0..0efc231f6 100644 --- a/src/frontend/src/services/PlanDataService.tsx +++ b/src/frontend/src/services/PlanDataService.tsx @@ -379,7 +379,7 @@ export class PlanDataService { } | null { try { // Unwrap wrapper - if (rawData && typeof rawData === 'object' && rawData.type === 'agent_message' && typeof rawData.data === 'string') { + if (rawData && typeof rawData === 'object' && rawData.type === WebsocketMessageType.AGENT_MESSAGE && typeof rawData.data === 'string') { return this.parseAgentMessage(rawData.data); } From c8e040525fdb1bbdc27f2865a8b66b8cbd041ee0 Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Sun, 7 Sep 2025 12:17:23 -0400 Subject: [PATCH 04/16] Refactor data_type usage and add PlanService Replaces string literals with DataType enum for data_type fields and queries in models and CosmosDB client for consistency and type safety. Adds PlanService to encapsulate plan approval logic and updates router to use this service, improving separation of concerns and error handling. --- src/backend/common/database/cosmosdb.py | 36 ++++++------- src/backend/common/models/messages_kernel.py | 16 +++--- src/backend/v3/api/router.py | 16 +++--- .../v3/common/services/plan_service.py | 53 +++++++++++++++++++ 4 files changed, 86 insertions(+), 35 deletions(-) create mode 100644 src/backend/v3/common/services/plan_service.py diff --git a/src/backend/common/database/cosmosdb.py b/src/backend/common/database/cosmosdb.py index 1f60d6e0f..057d0fd2a 100644 --- a/src/backend/common/database/cosmosdb.py +++ b/src/backend/common/database/cosmosdb.py @@ -38,12 +38,12 @@ class CosmosDBClient(DatabaseBase): """CosmosDB implementation of the database interface.""" MODEL_CLASS_MAPPING = { - "session": Session, - "plan": Plan, - "step": Step, - "agent_message": AgentMessage, - "team_config": TeamConfiguration, - "user_current_team": UserCurrentTeam, + DataType.session: Session, + DataType.plan: Plan, + DataType.step: Step, + DataType.agent_message: AgentMessage, + DataType.team_config: TeamConfiguration, + DataType.user_current_team: UserCurrentTeam, } def __init__( @@ -200,7 +200,7 @@ async def get_session(self, session_id: str) -> Optional[Session]: query = "SELECT * FROM c WHERE c.id=@id AND c.data_type=@data_type" parameters = [ {"name": "@id", "value": session_id}, - {"name": "@data_type", "value": "session"}, + {"name": "@data_type", "value": DataType.session}, ] results = await self.query_items(query, parameters, Session) return results[0] if results else None @@ -210,7 +210,7 @@ async def get_all_sessions(self) -> List[Session]: query = "SELECT * FROM c WHERE c.user_id=@user_id AND c.data_type=@data_type" parameters = [ {"name": "@user_id", "value": self.user_id}, - {"name": "@data_type", "value": "session"}, + {"name": "@data_type", "value": DataType.session}, ] return await self.query_items(query, parameters, Session) @@ -230,7 +230,7 @@ async def get_plan_by_session(self, session_id: str) -> Optional[Plan]: ) parameters = [ {"name": "@session_id", "value": session_id}, - {"name": "@data_type", "value": "plan"}, + {"name": "@data_type", "value": DataType.plan}, ] results = await self.query_items(query, parameters, Plan) return results[0] if results else None @@ -240,7 +240,7 @@ async def get_plan_by_plan_id(self, plan_id: str) -> Optional[Plan]: query = "SELECT * FROM c WHERE c.id=@plan_id AND c.data_type=@data_type" parameters = [ {"name": "@plan_id", "value": plan_id}, - {"name": "@data_type", "value": "plan"}, + {"name": "@data_type", "value": DataType.plan}, {"name": "@user_id", "value": self.user_id}, ] results = await self.query_items(query, parameters, Plan) @@ -255,7 +255,7 @@ async def get_all_plans(self) -> List[Plan]: query = "SELECT * FROM c WHERE c.user_id=@user_id AND c.data_type=@data_type" parameters = [ {"name": "@user_id", "value": self.user_id}, - {"name": "@data_type", "value": "plan"}, + {"name": "@data_type", "value": DataType.plan}, ] return await self.query_items(query, parameters, Plan) @@ -265,7 +265,7 @@ async def get_all_plans_by_team_id(self, team_id: str) -> List[Plan]: parameters = [ {"name": "@user_id", "value": self.user_id}, {"name": "@team_id", "value": team_id}, - {"name": "@data_type", "value": "plan"}, + {"name": "@data_type", "value": DataType.plan}, ] return await self.query_items(query, parameters, Plan) @@ -283,7 +283,7 @@ async def get_steps_by_plan(self, plan_id: str) -> List[Step]: query = "SELECT * FROM c WHERE c.plan_id=@plan_id AND c.data_type=@data_type ORDER BY c.timestamp" parameters = [ {"name": "@plan_id", "value": plan_id}, - {"name": "@data_type", "value": "step"}, + {"name": "@data_type", "value": DataType.step}, ] return await self.query_items(query, parameters, Step) @@ -293,7 +293,7 @@ async def get_step(self, step_id: str, session_id: str) -> Optional[Step]: parameters = [ {"name": "@step_id", "value": step_id}, {"name": "@session_id", "value": session_id}, - {"name": "@data_type", "value": "step"}, + {"name": "@data_type", "value": DataType.step}, ] results = await self.query_items(query, parameters, Step) return results[0] if results else None @@ -312,7 +312,7 @@ async def get_team(self, team_id: str) -> Optional[TeamConfiguration]: query = "SELECT * FROM c WHERE c.team_id=@team_id AND c.data_type=@data_type" parameters = [ {"name": "@team_id", "value": team_id}, - {"name": "@data_type", "value": "team_config"}, + {"name": "@data_type", "value": DataType.team_config}, ] teams = await self.query_items(query, parameters, TeamConfiguration) return teams[0] if teams else None @@ -329,7 +329,7 @@ async def get_team_by_id(self, id: str) -> Optional[TeamConfiguration]: query = "SELECT * FROM c WHERE c.id=@id AND c.data_type=@data_type" parameters = [ {"name": "@id", "value": id}, - {"name": "@data_type", "value": "team_config"}, + {"name": "@data_type", "value": DataType.team_config}, ] teams = await self.query_items(query, parameters, TeamConfiguration) return teams[0] if teams else None @@ -346,7 +346,7 @@ async def get_all_teams_by_user(self, user_id: str) -> List[TeamConfiguration]: query = "SELECT * FROM c WHERE c.user_id=@user_id AND c.data_type=@data_type ORDER BY c.created DESC" parameters = [ {"name": "@user_id", "value": user_id}, - {"name": "@data_type", "value": "team_config"}, + {"name": "@data_type", "value": DataType.team_config}, ] teams = await self.query_items(query, parameters, TeamConfiguration) return teams @@ -452,7 +452,7 @@ async def get_current_team(self, user_id: str) -> Optional[UserCurrentTeam]: query = "SELECT * FROM c WHERE c.data_type=@data_type AND c.user_id=@user_id" parameters = [ - {"name": "@data_type", "value": "user_current_team"}, + {"name": "@data_type", "value": DataType.user_current_team}, {"name": "@user_id", "value": user_id}, ] diff --git a/src/backend/common/models/messages_kernel.py b/src/backend/common/models/messages_kernel.py index 50c3bf1d7..d12c9c1c6 100644 --- a/src/backend/common/models/messages_kernel.py +++ b/src/backend/common/models/messages_kernel.py @@ -12,8 +12,8 @@ class DataType(str, Enum): session = "session" plan = "plan" step = "step" - message = "agent_message" - team = "team_config" + agent_message = "agent_message" + team_config = "team_config" user_current_team = "user_current_team" m_plan = "m_plan" m_plan_step = "m_plan_step" @@ -84,7 +84,7 @@ class BaseDataModel(KernelBaseModel): class AgentMessage(BaseDataModel): """Base class for messages sent between agents.""" - data_type: Literal["agent_message"] = Field("agent_message", Literal=True) + data_type: Literal[DataType.agent_message] = Field(DataType.agent_message, Literal=True) session_id: str user_id: str plan_id: str @@ -96,7 +96,7 @@ class AgentMessage(BaseDataModel): class Session(BaseDataModel): """Represents a user session.""" - data_type: Literal["session"] = Field("session", Literal=True) + data_type: Literal[DataType.session] = Field(DataType.session, Literal=True) user_id: str current_status: str message_to_user: Optional[str] = None @@ -105,7 +105,7 @@ class Session(BaseDataModel): class UserCurrentTeam(BaseDataModel): """Represents the current team of a user.""" - data_type: Literal["user_current_team"] = Field("user_current_team", Literal=True) + data_type: Literal[DataType.user_current_team] = Field(DataType.user_current_team, Literal=True) user_id: str team_id: str @@ -113,7 +113,7 @@ class UserCurrentTeam(BaseDataModel): class Plan(BaseDataModel): """Represents a plan containing multiple steps.""" - data_type: Literal["plan"] = Field("plan", Literal=True) + data_type: Literal[DataType.plan] = Field(DataType.plan, Literal=True) plan_id: str session_id: str user_id: str @@ -129,7 +129,7 @@ class Plan(BaseDataModel): class Step(BaseDataModel): """Represents an individual step (task) within a plan.""" - data_type: Literal["step"] = Field("step", Literal=True) + data_type: Literal[DataType.step] = Field(DataType.step, Literal=True) plan_id: str session_id: str # Partition key user_id: str @@ -181,7 +181,7 @@ class TeamConfiguration(BaseDataModel): """Represents a team configuration stored in the database.""" team_id: str - data_type: Literal["team_config"] = Field("team_config", Literal=True) + data_type: Literal[DataType.team_config] = Field(DataType.team_config, Literal=True) session_id: str # Partition key name: str status: str diff --git a/src/backend/v3/api/router.py b/src/backend/v3/api/router.py index 8fca48eba..c34f20a9c 100644 --- a/src/backend/v3/api/router.py +++ b/src/backend/v3/api/router.py @@ -7,6 +7,7 @@ from common.utils.utils_date import format_dates_in_messages from common.config.app_config import config +from v3.common.services.plan_service import PlanService import v3.models.messages as messages from auth.auth_utils import get_authenticated_user_details from common.database.database_factory import DatabaseFactory @@ -380,16 +381,12 @@ async def plan_approval( # orchestration_config.plans[human_feedback.m_plan_id], # ) try: - plan = orchestration_config.plans[human_feedback.m_plan_id] - if hasattr(plan, "plan_id"): - print( - "Updated orchestration config:", - orchestration_config.plans[human_feedback.m_plan_id], - ) - plan.plan_id = human_feedback.plan_id - orchestration_config.plans[human_feedback.m_plan_id] = plan + result = await PlanService.handle_plan_approval(human_feedback, user_id) + print("Plan approval processed:", result) + except ValueError as ve: + print(f"ValueError processing plan approval: {ve}") except Exception as e: - print(f"Error processing plan approval: {e}") + print(f"Error processing plan approval: {e}") track_event_if_configured( "PlanApprovalReceived", { @@ -400,6 +397,7 @@ async def plan_approval( "feedback": human_feedback.feedback, }, ) + return {"status": "approval recorded"} else: logging.warning( diff --git a/src/backend/v3/common/services/plan_service.py b/src/backend/v3/common/services/plan_service.py new file mode 100644 index 000000000..62def1035 --- /dev/null +++ b/src/backend/v3/common/services/plan_service.py @@ -0,0 +1,53 @@ +import logging +from typing import Dict, Any, Optional +from common.database.database_factory import DatabaseFactory +from common.database.database_base import DatabaseBase +import v3.models.messages as messages +from v3.config.settings import orchestration_config +from common.utils.event_utils import track_event_if_configured + +logger = logging.getLogger(__name__) + +class PlanService: + + + @staticmethod + async def handle_plan_approval(human_feedback: messages.PlanApprovalResponse, user_id: str) -> bool: + """ + Process a PlanApprovalResponse coming from the client. + + Args: + feedback: messages.PlanApprovalResponse (contains m_plan_id, plan_id, approved, feedback) + user_id: authenticated user id + + Returns: + dict with status and metadata + + Raises: + ValueError on invalid state + """ + if orchestration_config is None: + return False + try: + mplan = orchestration_config.plans[human_feedback.m_plan_id] + if hasattr(mplan, "plan_id"): + print( + "Updated orchestration config:", + orchestration_config.plans[human_feedback.m_plan_id], + ) + mplan.plan_id = human_feedback.plan_id + orchestration_config.plans[human_feedback.m_plan_id] = mplan + memory_store = await DatabaseFactory.get_database(user_id=user_id) + plan = await memory_store.get_plan(human_feedback.plan_id) + if plan: + print("Retrieved plan from memory store:", plan) + + + else: + print("Plan not found in memory store.") + return False + + except Exception as e: + print(f"Error processing plan approval: {e}") + return False + return True From 358ffb1062f973a55ed0efc9bab441c0fd3e3d36 Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Sun, 7 Sep 2025 13:49:51 -0400 Subject: [PATCH 05/16] Add TeamSelected component and update team selection logic Introduces the TeamSelected component for displaying the current team. Updates PlanPanelLeft, HomePage, and PlanPage to support an isHomePage prop and refines team selection and upload handling. Also updates PlanPanelLefProps interface to include isHomePage. --- .../src/components/common/TeamSelected.tsx | 21 ++ .../src/components/common/TeamSelector.tsx | 336 +++++++++--------- .../src/components/content/PlanPanelLeft.tsx | 46 +-- src/frontend/src/models/planPanelLeft.tsx | 1 + src/frontend/src/pages/HomePage.tsx | 1 + src/frontend/src/pages/PlanPage.tsx | 38 +- 6 files changed, 219 insertions(+), 224 deletions(-) create mode 100644 src/frontend/src/components/common/TeamSelected.tsx diff --git a/src/frontend/src/components/common/TeamSelected.tsx b/src/frontend/src/components/common/TeamSelected.tsx new file mode 100644 index 000000000..8aedbe7d3 --- /dev/null +++ b/src/frontend/src/components/common/TeamSelected.tsx @@ -0,0 +1,21 @@ +import { TeamConfig } from "@/models"; +import { Body1, Caption1 } from "@fluentui/react-components"; + +export interface TeamSelectedProps { + selectedTeam?: TeamConfig | null; + styles: { [key: string]: string }; +} + +const TeamSelected: React.FC = ({ selectedTeam, styles }) => { + return ( +
+ + Current Team + + + {selectedTeam ? selectedTeam.name : 'No team selected'} + +
+ ); +} +export default TeamSelected; \ No newline at end of file diff --git a/src/frontend/src/components/common/TeamSelector.tsx b/src/frontend/src/components/common/TeamSelector.tsx index 502e99fcf..62241f221 100644 --- a/src/frontend/src/components/common/TeamSelector.tsx +++ b/src/frontend/src/components/common/TeamSelector.tsx @@ -37,14 +37,14 @@ interface TeamSelectorProps { onTeamSelect?: (team: TeamConfig | null) => void; onTeamUpload?: () => Promise; selectedTeam?: TeamConfig | null; - sessionId?: string; + isHomePage: boolean; } const TeamSelector: React.FC = ({ onTeamSelect, onTeamUpload, selectedTeam, - sessionId, + isHomePage, }) => { const [isOpen, setIsOpen] = useState(false); const [teams, setTeams] = useState([]); @@ -96,60 +96,60 @@ const TeamSelector: React.FC = ({ } }; -const handleContinue = async () => { - if (!tempSelectedTeam) return; + const handleContinue = async () => { + if (!tempSelectedTeam) return; - setSelectionLoading(true); - setError(null); + setSelectionLoading(true); + setError(null); - try { - // If this team was just uploaded, skip the selection API call and go directly to homepage - if (uploadedTeam && uploadedTeam.team_id === tempSelectedTeam.team_id) { - console.log('Uploaded team selected, going directly to homepage:', tempSelectedTeam.name); - onTeamSelect?.(tempSelectedTeam); - setIsOpen(false); - return; // Skip the selectTeam API call - } + try { + // If this team was just uploaded, skip the selection API call and go directly to homepage + if (uploadedTeam && uploadedTeam.team_id === tempSelectedTeam.team_id) { + console.log('Uploaded team selected, going directly to homepage:', tempSelectedTeam.name); + onTeamSelect?.(tempSelectedTeam); + setIsOpen(false); + return; // Skip the selectTeam API call + } - // For existing teams, do the normal selection process - const result = await TeamService.selectTeam(tempSelectedTeam.team_id); + // For existing teams, do the normal selection process + const result = await TeamService.selectTeam(tempSelectedTeam.team_id); - if (result.success) { - console.log('Team selected:', result.data); - onTeamSelect?.(tempSelectedTeam); - setIsOpen(false); - } else { - setError(result.error || 'Failed to select team'); + if (result.success) { + console.log('Team selected:', result.data); + onTeamSelect?.(tempSelectedTeam); + setIsOpen(false); + } else { + setError(result.error || 'Failed to select team'); + } + } catch (err: any) { + console.error('Error selecting team:', err); + setError('Failed to select team. Please try again.'); + } finally { + setSelectionLoading(false); } - } catch (err: any) { - console.error('Error selecting team:', err); - setError('Failed to select team. Please try again.'); - } finally { - setSelectionLoading(false); - } -}; + }; const handleCancel = () => { setTempSelectedTeam(null); setIsOpen(false); }; - const filteredTeams = teams - .filter(team => { - const searchLower = searchQuery.toLowerCase(); - const nameMatch = team.name && team.name.toLowerCase().includes(searchLower); - const descriptionMatch = team.description && team.description.toLowerCase().includes(searchLower); - return nameMatch || descriptionMatch; - }) - .sort((a, b) => { - const aIsUploaded = uploadedTeam?.team_id === a.team_id; - const bIsUploaded = uploadedTeam?.team_id === b.team_id; - - if (aIsUploaded && !bIsUploaded) return -1; - if (!aIsUploaded && bIsUploaded) return 1; - - return 0; - }); + const filteredTeams = teams + .filter(team => { + const searchLower = searchQuery.toLowerCase(); + const nameMatch = team.name && team.name.toLowerCase().includes(searchLower); + const descriptionMatch = team.description && team.description.toLowerCase().includes(searchLower); + return nameMatch || descriptionMatch; + }) + .sort((a, b) => { + const aIsUploaded = uploadedTeam?.team_id === a.team_id; + const bIsUploaded = uploadedTeam?.team_id === b.team_id; + + if (aIsUploaded && !bIsUploaded) return -1; + if (!aIsUploaded && bIsUploaded) return 1; + + return 0; + }); const handleDeleteTeam = (team: TeamConfig, event: React.MouseEvent) => { event.stopPropagation(); @@ -214,14 +214,14 @@ const handleContinue = async () => { } }; - const handleFileUpload = async (event: React.ChangeEvent) => { + const handleFileUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; setUploadLoading(true); setError(null); setUploadMessage('Reading and validating team configuration...'); - setUploadSuccessMessage(null); + setUploadSuccessMessage(null); try { if (!file.name.toLowerCase().endsWith('.json')) { @@ -242,11 +242,11 @@ const handleContinue = async () => { // Check for duplicate team names or IDs if (teamData.name) { - const existingTeam = teams.find(team => + const existingTeam = teams.find(team => team.name.toLowerCase() === teamData.name.toLowerCase() || (teamData.team_id && team.team_id === teamData.team_id) ); - + if (existingTeam) { throw new Error(`A team with the name "${teamData.name}" already exists. Please choose a different name or modify the existing team.`); } @@ -261,11 +261,11 @@ const handleContinue = async () => { if (result.team) { // Set success message with team name setUploadSuccessMessage(`${result.team.name} was uploaded`); - + setTeams(currentTeams => [result.team!, ...currentTeams]); setUploadedTeam(result.team); setTempSelectedTeam(result.team); - + setTimeout(() => { setUploadSuccessMessage(null); }, 15000); @@ -343,11 +343,11 @@ const handleContinue = async () => { // Check for duplicate team names or IDs if (teamData.name) { - const existingTeam = teams.find(team => + const existingTeam = teams.find(team => team.name.toLowerCase() === teamData.name.toLowerCase() || (teamData.team_id && team.team_id === teamData.team_id) ); - + if (existingTeam) { throw new Error(`A team with the name "${teamData.name}" already exists. Please choose a different name or modify the existing team.`); } @@ -358,15 +358,15 @@ const handleContinue = async () => { if (result.success) { setUploadMessage(null); - + if (result.team) { // Set success message with team name setUploadSuccessMessage(`${result.team.name} was uploaded and selected`); - + setTeams(currentTeams => [result.team!, ...currentTeams]); setUploadedTeam(result.team); setTempSelectedTeam(result.team); - + // Clear success message after 15 seconds if user doesn't act setTimeout(() => { setUploadSuccessMessage(null); @@ -419,37 +419,37 @@ const handleContinue = async () => {
{team.name}
- + {/* Team description */}
{team.description}
- + {/* Agent badges - show agent names only */} -
- {team.agents.slice(0, 4).map((agent) => ( - - {agent.name} - - ))} - {team.agents.length > 4 && ( - - +{team.agents.length - 4} - - )} -
+
+ {team.agents.slice(0, 4).map((agent) => ( + + {agent.name} + + ))} + {team.agents.length > 4 && ( + + +{team.agents.length - 4} + + )} +
{/* Three-dot Menu Button */} + diff --git a/src/frontend/src/components/content/PlanPanelLeft.tsx b/src/frontend/src/components/content/PlanPanelLeft.tsx index fbb67a127..d351ff6a6 100644 --- a/src/frontend/src/components/content/PlanPanelLeft.tsx +++ b/src/frontend/src/components/content/PlanPanelLeft.tsx @@ -31,12 +31,13 @@ import { getUserInfoGlobal } from "@/api/config"; import TeamSelector from "../common/TeamSelector"; import { TeamConfig } from "../../models/Team"; -const PlanPanelLeft: React.FC = ({ - reloadTasks, - restReload, - onTeamSelect, +const PlanPanelLeft: React.FC = ({ + reloadTasks, + restReload, + onTeamSelect, onTeamUpload, - selectedTeam: parentSelectedTeam + isHomePage, + selectedTeam: parentSelectedTeam }) => { const { dispatchToast } = useToastController("toast"); const navigate = useNavigate(); @@ -50,7 +51,7 @@ const PlanPanelLeft: React.FC = ({ const [userInfo, setUserInfo] = useState( getUserInfoGlobal() ); - + // Use parent's selected team if provided, otherwise use local state const [localSelectedTeam, setLocalSelectedTeam] = useState(null); const selectedTeam = parentSelectedTeam || localSelectedTeam; @@ -78,7 +79,7 @@ const PlanPanelLeft: React.FC = ({ } }, [reloadTasks, loadPlansData, restReload]); // Fetch plans - + useEffect(() => { loadPlansData(); @@ -134,25 +135,25 @@ const PlanPanelLeft: React.FC = ({ setLocalSelectedTeam(team); dispatchToast( - Team Selected - - {team.name} team has been selected with {team.agents.length} agents - - , - { intent: "success" } - ); + Team Selected + + {team.name} team has been selected with {team.agents.length} agents + + , + { intent: "success" } + ); } else { // Handle team deselection (null case) setLocalSelectedTeam(null); dispatchToast( - Team Deselected - - No team is currently selected - - , - { intent: "info" } - ); + Team Deselected + + No team is currently selected + + , + { intent: "info" } + ); } } }, @@ -171,11 +172,12 @@ const PlanPanelLeft: React.FC = ({ {/* Team Selector right under the toolbar */} -
+
void; onTeamSelect?: (team: TeamConfig | null) => void; onTeamUpload?: () => Promise; + isHomePage: boolean; selectedTeam?: TeamConfig | null; } \ No newline at end of file diff --git a/src/frontend/src/pages/HomePage.tsx b/src/frontend/src/pages/HomePage.tsx index d42378eed..8e8b97cff 100644 --- a/src/frontend/src/pages/HomePage.tsx +++ b/src/frontend/src/pages/HomePage.tsx @@ -229,6 +229,7 @@ const HomePage: React.FC = () => { onNewTaskButton={handleNewTaskButton} onTeamSelect={handleTeamSelect} onTeamUpload={handleTeamUpload} + isHomePage={true} selectedTeam={selectedTeam} /> diff --git a/src/frontend/src/pages/PlanPage.tsx b/src/frontend/src/pages/PlanPage.tsx index 550abf945..d1f7f3778 100644 --- a/src/frontend/src/pages/PlanPage.tsx +++ b/src/frontend/src/pages/PlanPage.tsx @@ -146,6 +146,7 @@ const PlanPage: React.FC = () => { }, [planId, loading]); useEffect(() => { + const loadTeamConfig = async () => { try { setLoadingTeamConfig(true); @@ -181,29 +182,6 @@ const PlanPage: React.FC = () => { let actualPlanId = planId; let planResult: ProcessedPlanData | null = null; - // Check if this looks like a session_id (starts with "sid_" or "session_") - if (planId.startsWith('sid_') || planId.startsWith('session_')) { - console.log('Detected session_id, resolving to plan_id:', planId); - - try { - // Try to find the plan by session_id - const plansWithSteps = await apiService.getPlans(); - const matchingPlan = plansWithSteps.find((p: PlanWithSteps) => p.session_id === planId); - - if (matchingPlan) { - actualPlanId = matchingPlan.id; - planResult = convertToProcessedPlanData(matchingPlan); - console.log('Resolved session_id to plan_id:', actualPlanId); - } else { - console.error('No plan found with session_id:', planId); - throw new Error('Plan not found'); - } - } catch (sessionError) { - console.error('Failed to resolve session_id to plan_id:', sessionError); - throw sessionError; - } - } - if (actualPlanId && !planResult) { console.log("Fetching plan with ID:", actualPlanId); planResult = await PlanDataService.fetchPlanData(actualPlanId, useCache); @@ -322,16 +300,7 @@ const PlanPage: React.FC = () => { navigate("/", { state: { focusInput: true } }); }, [navigate]); - const handleTeamSelect = useCallback((team: TeamConfig | null) => { - setTeamConfig(team); - }, []); - const handleTeamUpload = useCallback(async () => { - // Reload team configurations - const teams = await TeamService.getUserTeams(); - const config = teams.length > 0 ? teams[0] : null; - setTeamConfig(config); - }, []); const resetReload = useCallback(() => { setReloadLeftList(false); @@ -379,8 +348,9 @@ const PlanPage: React.FC = () => { reloadTasks={reloadLeftList} onNewTaskButton={handleNewTaskButton} restReload={resetReload} - onTeamSelect={handleTeamSelect} - onTeamUpload={handleTeamUpload} + onTeamSelect={() => { }} + onTeamUpload={async () => { }} + isHomePage={false} selectedTeam={teamConfig} /> From f76bb3fe55c0b9a4b3e812613474c00ddf57c9ba Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Sun, 7 Sep 2025 14:14:31 -0400 Subject: [PATCH 06/16] Fix team ID reference in get_plans endpoint Replaces usage of current_team.id with current_team.team_id when fetching plans, ensuring correct team identifier is used for plan retrieval. --- src/backend/v3/api/router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/v3/api/router.py b/src/backend/v3/api/router.py index c34f20a9c..f4ee3d338 100644 --- a/src/backend/v3/api/router.py +++ b/src/backend/v3/api/router.py @@ -1028,7 +1028,7 @@ async def get_plans(request: Request): if not current_team: return [] - all_plans = await memory_store.get_all_plans_by_team_id(team_id=current_team.id) + all_plans = await memory_store.get_all_plans_by_team_id(team_id=current_team.team_id) return all_plans From 7dbf4e3525ea33ddb3d727f41602d6c8c26b649f Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Sun, 7 Sep 2025 15:12:29 -0400 Subject: [PATCH 07/16] Update plan model and status handling across backend and frontend Refactored Plan model fields in frontend to match backend, replacing 'title' and 'description' with 'initial_goal' and updating status fields. Adjusted backend to ensure plan completion only when total steps are greater than zero. Updated API and TaskService to use new fields and status logic for consistency. --- src/backend/common/models/messages_kernel.py | 6 ++++-- src/backend/v3/api/router.py | 11 ++++++++++- src/frontend/src/models/plan.tsx | 11 +++++------ src/frontend/src/services/TaskService.tsx | 6 +++--- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/backend/common/models/messages_kernel.py b/src/backend/common/models/messages_kernel.py index d12c9c1c6..f27f8cb44 100644 --- a/src/backend/common/models/messages_kernel.py +++ b/src/backend/common/models/messages_kernel.py @@ -232,9 +232,11 @@ def update_step_counts(self): self.completed = status_counts[StepStatus.completed] self.failed = status_counts[StepStatus.failed] - # Mark the plan as complete if the sum of completed and failed steps equals the total number of steps - if self.completed + self.failed == self.total_steps: + + if self.total_steps > 0 and (self.completed + self.failed) == self.total_steps: self.overall_status = PlanStatus.completed + # Mark the plan as complete if the sum of completed and failed steps equals the total number of steps + # Message classes for communication between agents diff --git a/src/backend/v3/api/router.py b/src/backend/v3/api/router.py index f4ee3d338..33fd83277 100644 --- a/src/backend/v3/api/router.py +++ b/src/backend/v3/api/router.py @@ -1030,7 +1030,16 @@ async def get_plans(request: Request): all_plans = await memory_store.get_all_plans_by_team_id(team_id=current_team.team_id) - return all_plans + steps_for_all_plans = [] + # Create list of PlanWithSteps and update step counts + list_of_plans_with_steps = [] + for plan in all_plans: + plan_with_steps = PlanWithSteps(**plan.model_dump(), steps=[]) + plan_with_steps.overall_status + plan_with_steps.update_step_counts() + list_of_plans_with_steps.append(plan_with_steps) + + return list_of_plans_with_steps # Get plans is called in the initial side rendering of the frontend diff --git a/src/frontend/src/models/plan.tsx b/src/frontend/src/models/plan.tsx index 91b5991c8..a14598c23 100644 --- a/src/frontend/src/models/plan.tsx +++ b/src/frontend/src/models/plan.tsx @@ -8,9 +8,9 @@ export interface BaseModel { /** Unique identifier */ id: string; /** Timestamp when created */ - created_at: string; + /** Timestamp when last updated */ - updated_at: string; + timestamp: string; } /** @@ -24,11 +24,10 @@ export interface Plan extends BaseModel { /** User identifier */ user_id: string; /** Plan title */ - title: string; - /** Plan description */ - description: string; + initial_goal: string; + /** Current status of the plan */ - status: PlanStatus; + overall_status: PlanStatus; /** Human clarification request text */ human_clarification_request?: string; /** Human clarification response text */ diff --git a/src/frontend/src/services/TaskService.tsx b/src/frontend/src/services/TaskService.tsx index 36ff32ea3..1a7f95afe 100644 --- a/src/frontend/src/services/TaskService.tsx +++ b/src/frontend/src/services/TaskService.tsx @@ -27,14 +27,14 @@ export class TaskService { plansData.forEach((plan) => { const task: Task = { id: plan.session_id, - name: plan.title, + name: plan.initial_goal, completed_steps: plan.completed, total_steps: plan.total_steps, - status: PlanDataService.isPlanComplete(plan) ? "completed" : "inprogress", + status: plan.overall_status === PlanStatus.COMPLETED ? "completed" : "inprogress", date: new Intl.DateTimeFormat(undefined, { dateStyle: "long", // timeStyle: "short", - }).format(new Date(plan.updated_at)), + }).format(new Date(plan.timestamp)), }; // Categorize based on plan status and completion From f1d3c44fc2725349b56b1cc28ff68a53699b0b2a Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Sun, 7 Sep 2025 15:15:56 -0400 Subject: [PATCH 08/16] Update plan property references in chat and service Replaced usage of 'description' with 'initial_goal' in PlanChat component and updated plan status check from 'status' to 'overall_status' in TaskService. These changes align with updated plan data structure. --- src/frontend/src/components/content/PlanChat.tsx | 8 ++++---- src/frontend/src/services/TaskService.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/components/content/PlanChat.tsx b/src/frontend/src/components/content/PlanChat.tsx index 787310787..1dcad8b96 100644 --- a/src/frontend/src/components/content/PlanChat.tsx +++ b/src/frontend/src/components/content/PlanChat.tsx @@ -180,10 +180,10 @@ const PlanChat: React.FC = ({ } // Check planData - if (planData?.plan?.description && - planData.plan.description.trim() && - planData.plan.description !== 'Task submitted') { - return planData.plan.description.trim(); + if (planData?.plan?.initial_goal && + planData.plan.initial_goal.trim() && + planData.plan.initial_goal !== 'Task submitted') { + return planData.plan.initial_goal.trim(); } // Default fallback diff --git a/src/frontend/src/services/TaskService.tsx b/src/frontend/src/services/TaskService.tsx index 1a7f95afe..16dee5df3 100644 --- a/src/frontend/src/services/TaskService.tsx +++ b/src/frontend/src/services/TaskService.tsx @@ -39,7 +39,7 @@ export class TaskService { // Categorize based on plan status and completion if ( - plan.status === PlanStatus.COMPLETED || + plan.overall_status === PlanStatus.COMPLETED || PlanDataService.isPlanComplete(plan) ) { completed.push(task); From 76213800a2a3889ba8de85c0cac93c189c6aae3b Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Sun, 7 Sep 2025 15:21:53 -0400 Subject: [PATCH 09/16] Update task list UI and task categorization logic Commented out in-progress step display in TaskList and simplified skeleton rendering logic. Modified TaskService to categorize completed tasks only by overall_status, removing PlanDataService.isPlanComplete check. --- src/frontend/src/components/content/TaskList.tsx | 12 ++++++------ src/frontend/src/services/TaskService.tsx | 3 +-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/frontend/src/components/content/TaskList.tsx b/src/frontend/src/components/content/TaskList.tsx index 47d2f0d39..65d9f6bc8 100644 --- a/src/frontend/src/components/content/TaskList.tsx +++ b/src/frontend/src/components/content/TaskList.tsx @@ -49,9 +49,9 @@ const TaskList: React.FC = ({ {task.date && task.status == "completed" && ( {task.date} )} - {task.status == "inprogress" && ( + {/* {task.status == "inprogress" && ( {`${task?.completed_steps} of ${task?.total_steps} completed`} - )} + )} */}
@@ -87,8 +87,8 @@ const TaskList: React.FC = ({ {loading ? Array.from({ length: 5 }, (_, i) => - renderSkeleton(`in-progress-${i}`) - ) + renderSkeleton(`in-progress-${i}`) + ) : inProgressTasks.map(renderTaskItem)} @@ -97,8 +97,8 @@ const TaskList: React.FC = ({ {loading ? Array.from({ length: 5 }, (_, i) => - renderSkeleton(`completed-${i}`) - ) + renderSkeleton(`completed-${i}`) + ) : completedTasks.map(renderTaskItem)} diff --git a/src/frontend/src/services/TaskService.tsx b/src/frontend/src/services/TaskService.tsx index 16dee5df3..9d3cc02b0 100644 --- a/src/frontend/src/services/TaskService.tsx +++ b/src/frontend/src/services/TaskService.tsx @@ -39,8 +39,7 @@ export class TaskService { // Categorize based on plan status and completion if ( - plan.overall_status === PlanStatus.COMPLETED || - PlanDataService.isPlanComplete(plan) + plan.overall_status === PlanStatus.COMPLETED ) { completed.push(task); } else { From fe6fa5745724b4632b1f203fafe70eaab1f66a90 Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Sun, 7 Sep 2025 15:56:53 -0400 Subject: [PATCH 10/16] Refactor plan approval request parsing logic Simplifies and restructures PlanDataService.parsePlanApprovalRequest to handle multiple input formats more robustly and consistently. Removes redundant code, improves normalization, and enhances step extraction. Adds a debug log for PLAN_APPROVAL_REQUEST in WebSocketService and adjusts agent message handling to always transform message data. --- src/frontend/src/services/PlanDataService.tsx | 345 +++++++++--------- .../src/services/WebSocketService.tsx | 3 +- 2 files changed, 171 insertions(+), 177 deletions(-) diff --git a/src/frontend/src/services/PlanDataService.tsx b/src/frontend/src/services/PlanDataService.tsx index 0efc231f6..373101290 100644 --- a/src/frontend/src/services/PlanDataService.tsx +++ b/src/frontend/src/services/PlanDataService.tsx @@ -154,208 +154,201 @@ export class PlanDataService { static parsePlanApprovalRequest(rawData: any): MPlanData | null { try { - console.log('🔍 Parsing plan approval request:', rawData, 'Type:', typeof rawData); - - // Already parsed object passthrough - if (rawData && typeof rawData === 'object' && rawData.type === WebsocketMessageType.PLAN_APPROVAL_REQUEST) { - return rawData.parsedData || null; - } - - // Wrapper form: { type: 'plan_approval_request', data: 'PlanApprovalRequest(plan=MPlan(...), ...)' } - if ( - rawData && - typeof rawData === 'object' && - rawData.type === 'plan_approval_request' && - typeof rawData.data === 'string' - ) { - // Recurse using the contained string - return this.parsePlanApprovalRequest(rawData.data); - } - - // Structured v3 style: { plan: { id, steps, user_request, ... }, context?: {...} } - if (rawData && typeof rawData === 'object' && rawData.plan && typeof rawData.plan === 'object') { - const mplan = rawData.plan; - - // Extract user_request text - let userRequestText = 'Plan approval required'; - if (mplan.user_request) { - if (typeof mplan.user_request === 'string') { - userRequestText = mplan.user_request; - } else if (Array.isArray(mplan.user_request.items)) { - const textContent = mplan.user_request.items.find((item: any) => item.text); - if (textContent?.text) { - userRequestText = textContent.text.replace(/\u200b/g, '').trim(); - } - } else if (mplan.user_request.content) { - userRequestText = mplan.user_request.content; - } - } - - const steps = (mplan.steps || []) - .map((step: any, index: number) => { + if (!rawData) return null; + + // Normalize to the PlanApprovalRequest(...) string that contains MPlan(...) + let source: string | null = null; + + if (typeof rawData === 'object') { + if (typeof rawData.data === 'string' && /PlanApprovalRequest\(plan=MPlan\(/.test(rawData.data)) { + source = rawData.data; + } else if (rawData.plan && typeof rawData.plan === 'object') { + // Already structured style + const mplan = rawData.plan; + const userRequestText = + typeof mplan.user_request === 'string' + ? mplan.user_request + : (Array.isArray(mplan.user_request?.items) + ? (mplan.user_request.items.find((i: any) => i.text)?.text || '') + : (mplan.user_request?.content || '') + ).replace?.(/\u200b/g, '').trim() || 'Plan approval required'; + + const steps = (mplan.steps || []).map((step: any, i: number) => { const action = step.action || ''; const cleanAction = action .replace(/\*\*/g, '') .replace(/^Certainly!\s*/i, '') .replace(/^Given the team composition and the available facts,?\s*/i, '') - .replace(/^here is a (?:concise )?plan to address the original request[^.]*\.\s*/i, '') + .replace(/^here is a (?:concise )?plan[^.]*\.\s*/i, '') .replace(/^(?:here is|this is) a (?:concise )?(?:plan|approach|strategy)[^.]*[.:]\s*/i, '') .replace(/^\*\*([^*]+)\*\*:?\s*/g, '$1: ') .replace(/^[-•]\s*/, '') .replace(/\s+/g, ' ') .trim(); - return { - id: index + 1, + id: i + 1, action, cleanAction, agent: step.agent || step._agent || 'System' }; - }) - .filter((s: any) => - s.cleanAction.length > 3 && - !/^(?:involvement|certainly|given|here is)/i.test(s.cleanAction) - ); - - return { - id: mplan.id || mplan.plan_id || 'unknown', - status: (mplan.overall_status || rawData.status || 'PENDING_APPROVAL'), - user_request: userRequestText, - team: Array.isArray(mplan.team) ? mplan.team : [], - facts: mplan.facts || '', - steps, - context: { - task: userRequestText, - participant_descriptions: rawData.context?.participant_descriptions || {} - }, - user_id: mplan.user_id, - team_id: mplan.team_id, - plan_id: mplan.plan_id, - overall_status: mplan.overall_status, - raw_data: rawData - }; + }).filter((s: any) => s.cleanAction.length > 3 && !/^(?:involvement|certainly|given|here is)/i.test(s.cleanAction)); + + + const result: MPlanData = { + id: mplan.id || mplan.plan_id || 'unknown', + status: (mplan.overall_status || rawData.status || 'PENDING_APPROVAL').toString().toUpperCase(), + user_request: userRequestText, + team: Array.isArray(mplan.team) ? mplan.team : [], + facts: mplan.facts || '', + steps, + context: { + task: userRequestText, + participant_descriptions: rawData.context?.participant_descriptions || {} + }, + user_id: mplan.user_id, + team_id: mplan.team_id, + plan_id: mplan.plan_id, + overall_status: mplan.overall_status, + raw_data: rawData + }; + return result; + } + } else if (typeof rawData === 'string') { + if (/PlanApprovalRequest\(plan=MPlan\(/.test(rawData)) { + source = rawData; + } else if (/^MPlan\(/.test(rawData)) { + source = `PlanApprovalRequest(plan=${rawData})`; + } } - // String representation parsing (PlanApprovalRequest(...MPlan(...)) or raw repr) - if (typeof rawData === 'string') { - const source = rawData; - - // Extract MPlan(...) block (optional) - // Not strictly needed but could be used for scoping later. - // const mplanBlock = source.match(/MPlan\(([\s\S]*?)\)\)/); - - // User request (first text='...') - let user_request = - source.match(/text=['"]([^'"]+?)['"]/) - ?.[1] - ?.replace(/\\u200b/g, '') - .trim() || 'Plan approval required'; - - const id = source.match(/MPlan\(id=['"]([^'"]+)['"]/)?.[1] || - source.match(/id=['"]([^'"]+)['"]/)?.[1] || - 'unknown'; - - let status = - source.match(/overall_status= s.trim().replace(/['"]/g, '')) - .filter(Boolean); - - const facts = - source - .match(/facts="([^"]*(?:\\.[^"]*)*)"/)?.[1] - ?.replace(/\\n/g, '\n') - .replace(/\\"/g, '"') || ''; - - // Steps: accept single or double quotes: action='...' or action="..." - const stepRegex = /MStep\(([^)]*?)\)/g; - const steps: any[] = []; - const uniqueActions = new Set(); - let match: RegExpExecArray | null; - let stepIndex = 1; - - while ((match = stepRegex.exec(source)) !== null) { - const chunk = match[1]; - const agent = - chunk.match(/agent=['"]([^'"]+)['"]/)?.[1] || 'System'; - const actionRaw = - chunk.match(/action=['"]([^'"]+)['"]/)?.[1] || ''; - - if (!actionRaw) continue; - - let cleanAction = actionRaw - .replace(/\*\*/g, '') - .replace(/^Certainly!\s*/i, '') - .replace(/^Given the team composition and the available facts,?\s*/i, '') - .replace(/^here is a (?:concise )?plan to[^.]*\.\s*/i, '') - .replace(/^\*\*([^*]+)\*\*:?\s*/g, '$1: ') - .replace(/^[-•]\s*/, '') - .replace(/\s+/g, ' ') - .trim(); - - if ( - cleanAction.length > 3 && - !uniqueActions.has(cleanAction.toLowerCase()) && - !/^(?:here is|this is|given|certainly|involvement)$/i.test(cleanAction) - ) { - uniqueActions.add(cleanAction.toLowerCase()); - steps.push({ - id: stepIndex++, - action: actionRaw, - cleanAction, - agent - }); - } - } + // Extract inner MPlan body + const mplanMatch = + source.match(/plan=MPlan\(([\s\S]*?)\),\s*status=/) || + source.match(/plan=MPlan\(([\s\S]*?)\)\s*\)/); + const body = mplanMatch ? mplanMatch[1] : null; + if (!body) return null; - // participant_descriptions (best-effort) - let participant_descriptions: Record = {}; - const pdMatch = - source.match(/participant_descriptions['"]?\s*:\s*({[^}]*})/) || - source.match(/'participant_descriptions':\s*({[^}]*})/); - - if (pdMatch?.[1]) { - const transformed = pdMatch[1] - .replace(/'/g, '"') - .replace(/([a-zA-Z0-9_]+)\s*:/g, '"$1":'); - try { - participant_descriptions = JSON.parse(transformed); - } catch { - participant_descriptions = {}; - } + const pick = (re: RegExp, upper = false): string | undefined => { + const m = body.match(re); + return m ? (upper ? m[1].toUpperCase() : m[1]) : undefined; + }; + + const id = pick(/id='([^']+)'/) || pick(/id="([^"]+)"/) || 'unknown'; + const user_id = pick(/user_id='([^']*)'/) || ''; + const team_id = pick(/team_id='([^']*)'/) || ''; + const plan_id = pick(/plan_id='([^']*)'/) || ''; + let overall_status = + pick(/overall_status= s.trim().replace(/['"]/g, '')) + .filter(Boolean); + + const facts = + body + .match(/facts="([^"]*(?:\\.[^"]*)*)"/)?.[1] + ?.replace(/\\n/g, '\n') + .replace(/\\"/g, '"') || ''; + + const steps: MPlanData['steps'] = []; + const stepRegex = /MStep\(([^)]*?)\)/g; + let stepMatch: RegExpExecArray | null; + let idx = 1; + const seen = new Set(); + while ((stepMatch = stepRegex.exec(body)) !== null) { + const chunk = stepMatch[1]; + const agent = + chunk.match(/agent='([^']+)'/)?.[1] || + chunk.match(/agent="([^"]+)"/)?.[1] || + 'System'; + const actionRaw = + chunk.match(/action='([^']+)'/)?.[1] || + chunk.match(/action="([^"]+)"/)?.[1] || + ''; + if (!actionRaw) continue; + + const cleanAction = actionRaw + .replace(/\*\*/g, '') + .replace(/^Certainly!\s*/i, '') + .replace(/^Given the team composition and the available facts,?\s*/i, '') + .replace(/^here is a (?:concise )?plan to[^.]*\.\s*/i, '') + .replace(/^\*\*([^*]+)\*\*:?\s*/g, '$1: ') + .replace(/^[-•]\s*/, '') + .replace(/\s+/g, ' ') + .trim(); + + const key = cleanAction.toLowerCase(); + if ( + cleanAction.length > 3 && + !seen.has(key) && + !/^(?:here is|this is|given|certainly|involvement)$/i.test(cleanAction) + ) { + seen.add(key); + steps.push({ + id: idx++, + action: actionRaw, + cleanAction, + agent + }); } + } - return { - id, - status, - user_request, - team, - facts, - steps, - context: { - task: user_request, - participant_descriptions - }, - raw_data: rawData - }; + let participant_descriptions: Record = {}; + const pdMatch = + source.match(/participant_descriptions['"]?\s*:\s*({[^}]*})/) || + source.match(/'participant_descriptions':\s*({[^}]*})/); + if (pdMatch?.[1]) { + const jsonish = pdMatch[1] + .replace(/'/g, '"') + .replace(/([a-zA-Z0-9_]+)\s*:/g, '"$1":'); + try { + participant_descriptions = JSON.parse(jsonish); + } catch { + participant_descriptions = {}; + } } - return null; - } catch (error) { - console.error('Error parsing plan approval request:', error); + const result: MPlanData = { + id, + status, + user_request, + team, + facts, + steps, + context: { + task: user_request, + participant_descriptions + }, + user_id, + team_id, + plan_id, + overall_status, + raw_data: rawData + }; + + return result; + } catch (e) { + console.error('parsePlanApprovalRequest failed:', e); return null; } } - // ...existing code... /** * Parse an agent message object or repr string: diff --git a/src/frontend/src/services/WebSocketService.tsx b/src/frontend/src/services/WebSocketService.tsx index 423791e46..553bb5be1 100644 --- a/src/frontend/src/services/WebSocketService.tsx +++ b/src/frontend/src/services/WebSocketService.tsx @@ -171,6 +171,7 @@ class WebSocketService { switch (message.type) { case WebsocketMessageType.PLAN_APPROVAL_REQUEST: { + console.log("enter plan approval request"); const parsedData = PlanDataService.parsePlanApprovalRequest(message.data); if (parsedData) { const structuredMessage: ParsedPlanApprovalRequest = { @@ -187,7 +188,7 @@ class WebSocketService { } case WebsocketMessageType.AGENT_MESSAGE: { - if (message.data && !message.data.plan_id && firstPlanId) { + if (message.data) { const transformed: StreamMessage = { ...message, data: { From e1f5f758496ec389d80b333767ef418d9a36f441 Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Sun, 7 Sep 2025 17:18:40 -0400 Subject: [PATCH 11/16] Refactor PlanChat to use streaming components Moved user plan, plan response, and thinking state rendering logic from PlanChat.tsx into dedicated streaming components for improved modularity and maintainability. Added StreamingPlanResponse, StreamingPlanState, StreamingUserPlan, and StreamingUserPlanMessage components. Updated PlanChat to use these new components and simplified its structure. --- .../src/components/content/PlanChat.tsx | 438 ++---------------- .../streaming/StreamingPlanResponse.tsx | 251 ++++++++++ .../content/streaming/StreamingPlanState.tsx | 47 ++ .../content/streaming/StreamingUserPlan.tsx | 41 ++ .../streaming/StreamingUserPlanMessage.tsx | 47 ++ 5 files changed, 426 insertions(+), 398 deletions(-) create mode 100644 src/frontend/src/components/content/streaming/StreamingPlanResponse.tsx create mode 100644 src/frontend/src/components/content/streaming/StreamingPlanState.tsx create mode 100644 src/frontend/src/components/content/streaming/StreamingUserPlan.tsx create mode 100644 src/frontend/src/components/content/streaming/StreamingUserPlanMessage.tsx diff --git a/src/frontend/src/components/content/PlanChat.tsx b/src/frontend/src/components/content/PlanChat.tsx index 1dcad8b96..79ebf4d72 100644 --- a/src/frontend/src/components/content/PlanChat.tsx +++ b/src/frontend/src/components/content/PlanChat.tsx @@ -25,6 +25,11 @@ import InlineToaster, { useInlineToaster, } from "../toast/InlineToaster"; import { WebsocketMessageType } from "@/models"; +import getUserPlan from "./streaming/StreamingUserPlan"; +import renderUserPlanMessage from "./streaming/StreamingUserPlanMessage"; +import renderPlanResponse from "./streaming/StreamingPlanResponse"; +import renderThinkingState from "./streaming/StreamingPlanState"; +import ContentNotFound from "../NotFound/ContentNotFound"; interface SimplifiedPlanChatProps extends PlanChatProps { onPlanReceived?: (planData: MPlanData) => void; initialTask?: string; @@ -155,371 +160,10 @@ const PlanChat: React.FC = ({ } }, [planApprovalRequest, planData, onPlanApproval, navigate]); - // Extract user task with better fallback logic - const getUserTask = () => { - // Check initialTask first - if (initialTask && initialTask.trim() && initialTask !== 'Task submitted') { - return initialTask.trim(); - } - - // Check parsed plan data - if (planApprovalRequest) { - // Check user_request field - if (planApprovalRequest.user_request && - planApprovalRequest.user_request.trim() && - planApprovalRequest.user_request !== 'Plan approval required') { - return planApprovalRequest.user_request.trim(); - } - - // Check context task - if (planApprovalRequest.context?.task && - planApprovalRequest.context.task.trim() && - planApprovalRequest.context.task !== 'Plan approval required') { - return planApprovalRequest.context.task.trim(); - } - } - - // Check planData - if (planData?.plan?.initial_goal && - planData.plan.initial_goal.trim() && - planData.plan.initial_goal !== 'Task submitted') { - return planData.plan.initial_goal.trim(); - } - - // Default fallback - // return 'Please create a plan for me'; - }; - - // Render user task message - const renderUserTaskMessage = () => { - const userTask = getUserTask(); - - return ( -
- {/* User Avatar */} -
- -
- - {/* User Message */} -
-
- {userTask} -
-
-
- ); - }; - - // Render AI thinking/planning state - const renderThinkingState = () => { - if (!waitingForPlan) return null; - - return ( -
- {/* AI Avatar */} -
- -
- - {/* Thinking Message */} -
-
- - Creating your plan... -
-
-
- ); - }; - - // Render the complete plan with all information - const renderPlanResponse = () => { - if (!planApprovalRequest) return null; - + if (!planData) return ( -
- {/* AI Avatar */} -
- -
- - {/* Plan Content */} -
- - {/* Plan Header */} -
-

- 📋 Plan Generated -

-
- Plan ID: {planApprovalRequest.id} - - {planApprovalRequest.status?.replace(/^.*'([^']*)'.*$/, '$1') || planApprovalRequest.status || 'PENDING_APPROVAL'} - -
-
- - {/* Analysis Section */} - {planApprovalRequest.facts && ( -
-

- 🔍 Analysis & Context -

-
- - {planApprovalRequest.facts} - -
-
- )} - - {/* Action Steps */} - {planApprovalRequest.steps && planApprovalRequest.steps.length > 0 && ( -
-

- 📝 Action Plan ({planApprovalRequest.steps.length} steps) -

-
- {planApprovalRequest.steps.map((step, index) => ( -
-
- {step.id} -
-
-
- - {step.cleanAction || step.action} - -
- {step.agent && step.agent !== 'System' && ( - - {step.agent} - - )} -
-
- ))} -
-
- )} - - {/* Team Assignment */} - {planApprovalRequest.team && planApprovalRequest.team.length > 0 && ( -
-

- 👥 Assigned Team -

-
- {planApprovalRequest.team.map((member, index) => ( - - {member} - - ))} -
-
- )} - - {/* Agent Capabilities */} - {planApprovalRequest.context?.participant_descriptions && - Object.keys(planApprovalRequest.context.participant_descriptions).length > 0 && ( -
-

- Agent Capabilities -

-
- {Object.entries(planApprovalRequest.context.participant_descriptions).map(([agent, description]) => ( -
-
{agent}:
-
{description}
-
- ))} -
-
- )} - - - {/* Action Buttons - Separate section */} -
-
- Ready for approval - -
- - - -
-
-
+ ); - }; - return (
= ({ width: '100%' }} > - {/* User task message */} - {renderUserTaskMessage()} + {/* User plan message */} + {renderUserPlanMessage(planApprovalRequest, initialTask, planData)} {/* AI thinking state */} - {renderThinkingState()} + {renderThinkingState(waitingForPlan)} {/* Plan response with all information */} - {renderPlanResponse()} + {renderPlanResponse(planApprovalRequest, handleApprovePlan, handleRejectPlan, processingApproval)} +
+ +
+ OnChatSubmit(input)} + disabledChat={submittingChatDisableInput || waitingForPlan} + placeholder={ + waitingForPlan + ? "Creating plan..." + : "Send a message..." + } + > +
- {/* Chat Input - only show if no plan is waiting for approval */} - {!planApprovalRequest && ( -
- OnChatSubmit(input)} - disabledChat={submittingChatDisableInput || waitingForPlan} - placeholder={ - waitingForPlan - ? "Creating plan..." - : "Send a message..." - } - > -
- )}
); }; diff --git a/src/frontend/src/components/content/streaming/StreamingPlanResponse.tsx b/src/frontend/src/components/content/streaming/StreamingPlanResponse.tsx new file mode 100644 index 000000000..cdb52c9d1 --- /dev/null +++ b/src/frontend/src/components/content/streaming/StreamingPlanResponse.tsx @@ -0,0 +1,251 @@ +import { MPlanData } from "@/models"; +import { Button, Spinner, Tag } from "@fluentui/react-components"; +import { BotRegular, CheckmarkRegular, DismissRegular } from "@fluentui/react-icons"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +// Render the complete plan with all information +const renderPlanResponse = (planApprovalRequest: MPlanData | null, handleApprovePlan: () => void, handleRejectPlan: () => void, processingApproval: boolean,) => { + if (!planApprovalRequest) return null; + + return ( +
+ {/* AI Avatar */} +
+ +
+ + {/* Plan Content */} +
+ + {/* Plan Header */} +
+

+ 📋 Plan Generated +

+
+ Plan ID: {planApprovalRequest.id} + + {planApprovalRequest.status?.replace(/^.*'([^']*)'.*$/, '$1') || planApprovalRequest.status || 'PENDING_APPROVAL'} + +
+
+ + {/* Analysis Section */} + {planApprovalRequest.facts && ( +
+

+ 🔍 Analysis & Context +

+
+ + {planApprovalRequest.facts} + +
+
+ )} + + {/* Action Steps */} + {planApprovalRequest.steps && planApprovalRequest.steps.length > 0 && ( +
+

+ 📝 Action Plan ({planApprovalRequest.steps.length} steps) +

+
+ {planApprovalRequest.steps.map((step, index) => ( +
+
+ {step.id} +
+
+
+ + {step.cleanAction || step.action} + +
+ {step.agent && step.agent !== 'System' && ( + + {step.agent} + + )} +
+
+ ))} +
+
+ )} + + {/* Team Assignment */} + {planApprovalRequest.team && planApprovalRequest.team.length > 0 && ( +
+

+ 👥 Assigned Team +

+
+ {planApprovalRequest.team.map((member, index) => ( + + {member} + + ))} +
+
+ )} + + {/* Agent Capabilities */} + {planApprovalRequest.context?.participant_descriptions && + Object.keys(planApprovalRequest.context.participant_descriptions).length > 0 && ( +
+

+ Agent Capabilities +

+
+ {Object.entries(planApprovalRequest.context.participant_descriptions).map(([agent, description]) => ( +
+
{agent}:
+
{description}
+
+ ))} +
+
+ )} + + + {/* Action Buttons - Separate section */} +
+
+ Ready for approval + +
+ + + +
+
+
+ ); +}; + +export default renderPlanResponse; \ No newline at end of file diff --git a/src/frontend/src/components/content/streaming/StreamingPlanState.tsx b/src/frontend/src/components/content/streaming/StreamingPlanState.tsx new file mode 100644 index 000000000..737b77dd9 --- /dev/null +++ b/src/frontend/src/components/content/streaming/StreamingPlanState.tsx @@ -0,0 +1,47 @@ +import { Spinner } from "@fluentui/react-components"; +import { BotRegular } from "@fluentui/react-icons"; + +// Render AI thinking/planning state +const renderThinkingState = (waitingForPlan: boolean) => { + if (!waitingForPlan) return null; + + return ( +
+ {/* AI Avatar */} +
+ +
+ + {/* Thinking Message */} +
+
+ + Creating your plan... +
+
+
+ ); +}; +export default renderThinkingState; \ No newline at end of file diff --git a/src/frontend/src/components/content/streaming/StreamingUserPlan.tsx b/src/frontend/src/components/content/streaming/StreamingUserPlan.tsx new file mode 100644 index 000000000..0b79b8404 --- /dev/null +++ b/src/frontend/src/components/content/streaming/StreamingUserPlan.tsx @@ -0,0 +1,41 @@ +import { MPlanData, ProcessedPlanData } from "@/models"; +import { get } from "http"; + +const getUserPlan = ( + planApprovalRequest: MPlanData | null, + initialTask?: string, + planData?: ProcessedPlanData +) => { + // Check initialTask first + if (initialTask && initialTask.trim() && initialTask !== 'Task submitted') { + return initialTask.trim(); + } + + // Check parsed plan data + if (planApprovalRequest) { + // Check user_request field + if (planApprovalRequest.user_request && + planApprovalRequest.user_request.trim() && + planApprovalRequest.user_request !== 'Plan approval required') { + return planApprovalRequest.user_request.trim(); + } + + // Check context task + if (planApprovalRequest.context?.task && + planApprovalRequest.context.task.trim() && + planApprovalRequest.context.task !== 'Plan approval required') { + return planApprovalRequest.context.task.trim(); + } + } + + // Check planData + if (planData?.plan?.initial_goal && + planData.plan.initial_goal.trim() && + planData.plan.initial_goal !== 'Task submitted') { + return planData.plan.initial_goal.trim(); + } + + // Default fallback + // return 'Please create a plan for me'; +}; +export default getUserPlan; \ No newline at end of file diff --git a/src/frontend/src/components/content/streaming/StreamingUserPlanMessage.tsx b/src/frontend/src/components/content/streaming/StreamingUserPlanMessage.tsx new file mode 100644 index 000000000..0082d5b5e --- /dev/null +++ b/src/frontend/src/components/content/streaming/StreamingUserPlanMessage.tsx @@ -0,0 +1,47 @@ +import { PersonRegular } from "@fluentui/react-icons"; +import getUserTask from "./StreamingUserPlan"; +import { MPlanData, ProcessedPlanData } from "@/models"; + +// Render user task message +const renderUserPlanMessage = (planApprovalRequest: MPlanData | null, + initialTask?: string, + planData?: ProcessedPlanData) => { + const userTask = getUserTask(planApprovalRequest, initialTask, planData); + + return ( +
+ {/* User Avatar */} +
+ +
+ + {/* User Message */} +
+
+ {userTask} +
+
+
+ ); +}; +export default renderUserPlanMessage; \ No newline at end of file From 40da5e9fc088f498d049d3f56d54b263e5f493ea Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Sun, 7 Sep 2025 18:19:12 -0400 Subject: [PATCH 12/16] Refactor PlanChat input into PlanChatBody component Moved the chat input UI from PlanChat.tsx into a new PlanChatBody.tsx component for better separation of concerns and maintainability. Also performed minor variable renaming in StreamingUserPlanMessage.tsx and removed an unused import in StreamingUserPlan.tsx. --- .../src/components/content/PlanChat.tsx | 41 ++++---------- .../src/components/content/PlanChatBody.tsx | 55 +++++++++++++++++++ .../content/streaming/StreamingUserPlan.tsx | 1 - .../streaming/StreamingUserPlanMessage.tsx | 4 +- 4 files changed, 69 insertions(+), 32 deletions(-) create mode 100644 src/frontend/src/components/content/PlanChatBody.tsx diff --git a/src/frontend/src/components/content/PlanChat.tsx b/src/frontend/src/components/content/PlanChat.tsx index 79ebf4d72..7d89fd762 100644 --- a/src/frontend/src/components/content/PlanChat.tsx +++ b/src/frontend/src/components/content/PlanChat.tsx @@ -30,6 +30,7 @@ import renderUserPlanMessage from "./streaming/StreamingUserPlanMessage"; import renderPlanResponse from "./streaming/StreamingPlanResponse"; import renderThinkingState from "./streaming/StreamingPlanState"; import ContentNotFound from "../NotFound/ContentNotFound"; +import PlanChatBody from "./PlanChatBody"; interface SimplifiedPlanChatProps extends PlanChatProps { onPlanReceived?: (planData: MPlanData) => void; initialTask?: string; @@ -55,6 +56,7 @@ const PlanChat: React.FC = ({ const [userFeedback, setUserFeedback] = useState(''); const [showFeedbackInput, setShowFeedbackInput] = useState(false); + // Auto-scroll helper const scrollToBottom = useCallback(() => { setTimeout(() => { @@ -193,35 +195,16 @@ const PlanChat: React.FC = ({ {renderPlanResponse(planApprovalRequest, handleApprovePlan, handleRejectPlan, processingApproval)} -
- OnChatSubmit(input)} - disabledChat={submittingChatDisableInput || waitingForPlan} - placeholder={ - waitingForPlan - ? "Creating plan..." - : "Send a message..." - } - > -
- + {/* Chat Input - only show if no plan is waiting for approval */} + ); }; diff --git a/src/frontend/src/components/content/PlanChatBody.tsx b/src/frontend/src/components/content/PlanChatBody.tsx new file mode 100644 index 000000000..8911353ff --- /dev/null +++ b/src/frontend/src/components/content/PlanChatBody.tsx @@ -0,0 +1,55 @@ +import ChatInput from "@/coral/modules/ChatInput"; +import { PlanChatProps } from "@/models"; +import { Button } from "@fluentui/react-components"; +import { SendRegular } from "@fluentui/react-icons"; + +interface SimplifiedPlanChatProps extends PlanChatProps { + showChatInput: boolean; + waitingForPlan: boolean; +} +const PlanChatBody: React.FC = ({ + planData, + input, + setInput, + submittingChatDisableInput, + OnChatSubmit, + showChatInput, + waitingForPlan + +}) => { + if (!showChatInput) { + return null; + } + return ( + +
+ OnChatSubmit(input)} + disabledChat={submittingChatDisableInput || waitingForPlan} + placeholder={ + waitingForPlan + ? "Creating plan..." + : "Send a message..." + } + > +
); +} + +export default PlanChatBody; \ No newline at end of file diff --git a/src/frontend/src/components/content/streaming/StreamingUserPlan.tsx b/src/frontend/src/components/content/streaming/StreamingUserPlan.tsx index 0b79b8404..a639167ee 100644 --- a/src/frontend/src/components/content/streaming/StreamingUserPlan.tsx +++ b/src/frontend/src/components/content/streaming/StreamingUserPlan.tsx @@ -1,5 +1,4 @@ import { MPlanData, ProcessedPlanData } from "@/models"; -import { get } from "http"; const getUserPlan = ( planApprovalRequest: MPlanData | null, diff --git a/src/frontend/src/components/content/streaming/StreamingUserPlanMessage.tsx b/src/frontend/src/components/content/streaming/StreamingUserPlanMessage.tsx index 0082d5b5e..23bb8e06f 100644 --- a/src/frontend/src/components/content/streaming/StreamingUserPlanMessage.tsx +++ b/src/frontend/src/components/content/streaming/StreamingUserPlanMessage.tsx @@ -6,7 +6,7 @@ import { MPlanData, ProcessedPlanData } from "@/models"; const renderUserPlanMessage = (planApprovalRequest: MPlanData | null, initialTask?: string, planData?: ProcessedPlanData) => { - const userTask = getUserTask(planApprovalRequest, initialTask, planData); + const userPlan = getUserTask(planApprovalRequest, initialTask, planData); return (
- {userTask} + {userPlan}
From 85d3525aaa439b3dee6e5b21a0673e3134b91a8e Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Sun, 7 Sep 2025 19:23:03 -0400 Subject: [PATCH 13/16] Refactor plan approval state management Moves plan approval request and waiting state management from PlanChat and PlanPanelRight into PlanPage, passing them as props. This centralizes WebSocket handling and plan state, simplifies component logic, and ensures consistent plan approval UI updates across components. --- .../src/components/content/PlanChat.tsx | 75 +++++------------- .../src/components/content/PlanPanelRight.tsx | 41 ++-------- .../streaming/StreamingPlanResponse.tsx | 79 ++++++++++--------- src/frontend/src/pages/PlanPage.tsx | 57 ++++++++++++- 4 files changed, 119 insertions(+), 133 deletions(-) diff --git a/src/frontend/src/components/content/PlanChat.tsx b/src/frontend/src/components/content/PlanChat.tsx index 7d89fd762..59a7fd6d9 100644 --- a/src/frontend/src/components/content/PlanChat.tsx +++ b/src/frontend/src/components/content/PlanChat.tsx @@ -34,6 +34,9 @@ import PlanChatBody from "./PlanChatBody"; interface SimplifiedPlanChatProps extends PlanChatProps { onPlanReceived?: (planData: MPlanData) => void; initialTask?: string; + planApprovalRequest: MPlanData | null; + waitingForPlan: boolean; + messagesContainerRef: React.RefObject; } const PlanChat: React.FC = ({ @@ -45,67 +48,25 @@ const PlanChat: React.FC = ({ onPlanApproval, onPlanReceived, initialTask, + planApprovalRequest, + waitingForPlan, + messagesContainerRef + }) => { const navigate = useNavigate(); - const messagesContainerRef = useRef(null); + const { showToast, dismissToast } = useInlineToaster(); // States - const [planApprovalRequest, setPlanApprovalRequest] = useState(null); + const [processingApproval, setProcessingApproval] = useState(false); - const [waitingForPlan, setWaitingForPlan] = useState(true); - const [userFeedback, setUserFeedback] = useState(''); - const [showFeedbackInput, setShowFeedbackInput] = useState(false); + + const [showApprovalButtons, setShowApprovalButtons] = useState(true); + - // Auto-scroll helper - const scrollToBottom = useCallback(() => { - setTimeout(() => { - if (messagesContainerRef.current) { - messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight; - } - }, 100); - }, []); // Listen for m_plan streaming - useEffect(() => { - const unsubscribe = webSocketService.on(WebsocketMessageType.PLAN_APPROVAL_REQUEST, (approvalRequest: any) => { - console.log('📋 Plan received:', approvalRequest); - - let mPlanData: MPlanData | null = null; - - // Handle the different message structures - if (approvalRequest.parsedData) { - // Direct parsedData property - mPlanData = approvalRequest.parsedData; - } else if (approvalRequest.data && typeof approvalRequest.data === 'object') { - // Data property with nested object - if (approvalRequest.data.parsedData) { - mPlanData = approvalRequest.data.parsedData; - } else { - // Try to parse the data object directly - mPlanData = approvalRequest.data; - } - } else if (approvalRequest.rawData) { - // Parse the raw data string - mPlanData = PlanDataService.parsePlanApprovalRequest(approvalRequest.rawData); - } else { - // Try to parse the entire object - mPlanData = PlanDataService.parsePlanApprovalRequest(approvalRequest); - } - - if (mPlanData) { - console.log('✅ Parsed plan data:', mPlanData); - setPlanApprovalRequest(mPlanData); - setWaitingForPlan(false); - onPlanReceived?.(mPlanData); - scrollToBottom(); - } else { - console.error('❌ Failed to parse plan data', approvalRequest); - } - }); - - return () => unsubscribe(); - }, [onPlanReceived, scrollToBottom]); + // Handle plan approval const handleApprovePlan = useCallback(async () => { @@ -118,12 +79,12 @@ const PlanChat: React.FC = ({ m_plan_id: planApprovalRequest.id, plan_id: planData?.plan?.id, approved: true, - feedback: userFeedback || 'Plan approved by user' + feedback: 'Plan approved by user' }); dismissToast(id); - setShowFeedbackInput(false); onPlanApproval?.(true); + setShowApprovalButtons(false); } catch (error) { dismissToast(id); @@ -132,7 +93,7 @@ const PlanChat: React.FC = ({ } finally { setProcessingApproval(false); } - }, [planApprovalRequest, planData, userFeedback, onPlanApproval]); + }, [planApprovalRequest, planData, onPlanApproval]); // Handle plan rejection const handleRejectPlan = useCallback(async () => { @@ -145,7 +106,7 @@ const PlanChat: React.FC = ({ m_plan_id: planApprovalRequest.id, plan_id: planData?.plan?.id, approved: false, - feedback: userFeedback || 'Plan rejected by user' + feedback: 'Plan rejected by user' }); dismissToast(id); @@ -192,7 +153,7 @@ const PlanChat: React.FC = ({ {renderThinkingState(waitingForPlan)} {/* Plan response with all information */} - {renderPlanResponse(planApprovalRequest, handleApprovePlan, handleRejectPlan, processingApproval)} + {renderPlanResponse(planApprovalRequest, handleApprovePlan, handleRejectPlan, processingApproval, showApprovalButtons)} {/* Chat Input - only show if no plan is waiting for approval */} diff --git a/src/frontend/src/components/content/PlanPanelRight.tsx b/src/frontend/src/components/content/PlanPanelRight.tsx index 1a1290eb5..2139ecb71 100644 --- a/src/frontend/src/components/content/PlanPanelRight.tsx +++ b/src/frontend/src/components/content/PlanPanelRight.tsx @@ -28,6 +28,8 @@ interface PlanPanelRightProps { planApproved: boolean; onPlanApproval?: (approved: boolean) => void; wsConnected?: boolean; + planApprovalRequest: MPlanData | null; + } interface GroupedMessage { @@ -56,9 +58,9 @@ const PlanPanelRight: React.FC = ({ streamingMessages = [], planApproved, wsConnected = false, + planApprovalRequest }) => { const [groupedStreamingMessages, setGroupedStreamingMessages] = useState([]); - const [planApprovalRequest, setPlanApprovalRequest] = useState(null); const [hasStreamingStarted, setHasStreamingStarted] = useState(false); // Helper function to get clean agent display name @@ -107,24 +109,7 @@ const PlanPanelRight: React.FC = ({ } }, [streamingMessages, hasStreamingStarted]); - // Add WebSocket listener for plan approval requests - but only store, don't display until streaming starts - useEffect(() => { - const unsubscribePlanApproval = webSocketService.onPlanApprovalRequest((approvalRequest) => { - if (approvalRequest.parsedData) { - const parsedData = PlanDataService.parsePlanApprovalRequest(approvalRequest); - if (parsedData) { - console.log('📥 Right panel received plan approval request:', parsedData); - setPlanApprovalRequest(parsedData); - // Reset states when new plan comes in - setHasStreamingStarted(false); - } - } - }); - return () => { - unsubscribePlanApproval(); - }; - }, []); // Group streaming messages by agent const groupStreamingMessages = useCallback((messages: StreamingPlanUpdate[]): GroupedMessage[] => { @@ -170,7 +155,7 @@ const PlanPanelRight: React.FC = ({ }, [streamingMessages, groupStreamingMessages]); // ✅ NEW: Get comprehensive agent status combining planned and streaming agents - const getAgentStatus = useCallback((): AgentStatus[] => { + const getAgentStatus = useCallback((planApprovalRequest: MPlanData | null): AgentStatus[] => { const agentStatusMap = new Map(); // Add planned agents from the plan approval request @@ -249,21 +234,7 @@ const PlanPanelRight: React.FC = ({ // ✅ ENHANCED: Show waiting message only when we don't have plan approval request if (!planApprovalRequest) { - return ( -
- Waiting for plan creation... -
- ); + return null; } // Render Plan Section - show once we have plan approval request @@ -354,7 +325,7 @@ const PlanPanelRight: React.FC = ({ // ✅ ENHANCED: Render Agents Section - show planned agents immediately, update with streaming status const renderAgentsSection = () => { - const agents = getAgentStatus(); + const agents = getAgentStatus(planApprovalRequest); return (
void, handleRejectPlan: () => void, processingApproval: boolean,) => { +const renderPlanResponse = (planApprovalRequest: MPlanData | null, handleApprovePlan: () => void, handleRejectPlan: () => void, processingApproval: boolean, showApprovalButtons: boolean) => { if (!planApprovalRequest) return null; return ( @@ -55,7 +55,7 @@ const renderPlanResponse = (planApprovalRequest: MPlanData | null, handleApprove }}> Plan ID: {planApprovalRequest.id} - {planApprovalRequest.status?.replace(/^.*'([^']*)'.*$/, '$1') || planApprovalRequest.status || 'PENDING_APPROVAL'} + {planApprovalRequest.status?.replace(/^.*'([^']*)'.*$/, '$1') || planApprovalRequest.status || 'Pending'}
@@ -203,46 +203,49 @@ const renderPlanResponse = (planApprovalRequest: MPlanData | null, handleApprove {/* Action Buttons - Separate section */} -
+ {showApprovalButtons && <>
- Ready for approval +
+ Ready for approval -
+
- - -
+ + + + + } ); diff --git a/src/frontend/src/pages/PlanPage.tsx b/src/frontend/src/pages/PlanPage.tsx index d1f7f3778..58969d81c 100644 --- a/src/frontend/src/pages/PlanPage.tsx +++ b/src/frontend/src/pages/PlanPage.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState, useMemo } from "react" import { useParams, useNavigate } from "react-router-dom"; import { Spinner, Text } from "@fluentui/react-components"; import { PlanDataService } from "../services/PlanDataService"; -import { ProcessedPlanData, PlanWithSteps, WebsocketMessageType } from "../models"; +import { ProcessedPlanData, PlanWithSteps, WebsocketMessageType, MPlanData } from "../models"; import PlanChat from "../components/content/PlanChat"; import PlanPanelRight from "../components/content/PlanPanelRight"; import PlanPanelLeft from "../components/content/PlanPanelLeft"; @@ -37,7 +37,7 @@ const PlanPage: React.FC = () => { const { planId } = useParams<{ planId: string }>(); const navigate = useNavigate(); const { showToast, dismissToast } = useInlineToaster(); - + const messagesContainerRef = useRef(null); const [input, setInput] = useState(""); const [planData, setPlanData] = useState(null); const [allPlans, setAllPlans] = useState([]); @@ -47,8 +47,9 @@ const PlanPage: React.FC = () => { const [processingSubtaskId, setProcessingSubtaskId] = useState( null ); + const [planApprovalRequest, setPlanApprovalRequest] = useState(null); const [reloadLeftList, setReloadLeftList] = useState(true); - + const [waitingForPlan, setWaitingForPlan] = useState(true); // WebSocket connection state const [wsConnected, setWsConnected] = useState(false); const [streamingMessages, setStreamingMessages] = useState([]); @@ -67,8 +68,54 @@ const PlanPage: React.FC = () => { // Use ref to store the function to avoid stale closure issues const loadPlanDataRef = useRef<() => Promise>(); + // Auto-scroll helper + const scrollToBottom = useCallback(() => { + setTimeout(() => { + if (messagesContainerRef.current) { + messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight; + } + }, 100); + }, []); + + useEffect(() => { + const unsubscribe = webSocketService.on(WebsocketMessageType.PLAN_APPROVAL_REQUEST, (approvalRequest: any) => { + console.log('📋 Plan received:', approvalRequest); + + let mPlanData: MPlanData | null = null; + + // Handle the different message structures + if (approvalRequest.parsedData) { + // Direct parsedData property + mPlanData = approvalRequest.parsedData; + } else if (approvalRequest.data && typeof approvalRequest.data === 'object') { + // Data property with nested object + if (approvalRequest.data.parsedData) { + mPlanData = approvalRequest.data.parsedData; + } else { + // Try to parse the data object directly + mPlanData = approvalRequest.data; + } + } else if (approvalRequest.rawData) { + // Parse the raw data string + mPlanData = PlanDataService.parsePlanApprovalRequest(approvalRequest.rawData); + } else { + // Try to parse the entire object + mPlanData = PlanDataService.parsePlanApprovalRequest(approvalRequest); + } + if (mPlanData) { + console.log('✅ Parsed plan data:', mPlanData); + setPlanApprovalRequest(mPlanData); + setWaitingForPlan(false); + // onPlanReceived?.(mPlanData); + // scrollToBottom(); + } else { + console.error('❌ Failed to parse plan data', approvalRequest); + } + }); + return () => unsubscribe(); + }, [scrollToBottom]); //onPlanReceived, scrollToBottom // Loading message rotation effect useEffect(() => { let interval: NodeJS.Timeout; @@ -405,6 +452,9 @@ const PlanPage: React.FC = () => { streamingMessages={streamingMessages} wsConnected={wsConnected} onPlanApproval={(approved) => setPlanApproved(approved)} + planApprovalRequest={planApprovalRequest} + waitingForPlan={waitingForPlan} + messagesContainerRef={messagesContainerRef} /> )} @@ -417,6 +467,7 @@ const PlanPage: React.FC = () => { loading={loading} streamingMessages={streamingMessages} planApproved={planApproved} + planApprovalRequest={planApprovalRequest} /> From 6d7331596c0a615fe4ad9c3afd35a3e1aaec5332 Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Sun, 7 Sep 2025 19:27:24 -0400 Subject: [PATCH 14/16] Update PlanPage.tsx --- src/frontend/src/pages/PlanPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/pages/PlanPage.tsx b/src/frontend/src/pages/PlanPage.tsx index 58969d81c..dc8b85a24 100644 --- a/src/frontend/src/pages/PlanPage.tsx +++ b/src/frontend/src/pages/PlanPage.tsx @@ -108,7 +108,7 @@ const PlanPage: React.FC = () => { setPlanApprovalRequest(mPlanData); setWaitingForPlan(false); // onPlanReceived?.(mPlanData); - // scrollToBottom(); + scrollToBottom(); } else { console.error('❌ Failed to parse plan data', approvalRequest); } From 0b805d9226b809cf020491bbc589fd9e5a0d0530 Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Sun, 7 Sep 2025 20:59:13 -0400 Subject: [PATCH 15/16] Add streaming buffer message display to PlanChat Introduces a new StreamingBufferMessage component to render live agent message streams in PlanChat. Updates PlanPage and WebSocketService to handle and buffer streaming agent messages, improving real-time feedback during plan generation. --- .../src/components/content/PlanChat.tsx | 10 +++- .../src/components/content/PlanChatBody.tsx | 1 - .../streaming/StreamingBufferMessage.tsx | 32 +++++++++++++ src/frontend/src/pages/PlanPage.tsx | 48 +++++++++++++------ .../src/services/WebSocketService.tsx | 39 +++------------ 5 files changed, 81 insertions(+), 49 deletions(-) create mode 100644 src/frontend/src/components/content/streaming/StreamingBufferMessage.tsx diff --git a/src/frontend/src/components/content/PlanChat.tsx b/src/frontend/src/components/content/PlanChat.tsx index 59a7fd6d9..319cfec36 100644 --- a/src/frontend/src/components/content/PlanChat.tsx +++ b/src/frontend/src/components/content/PlanChat.tsx @@ -31,12 +31,15 @@ import renderPlanResponse from "./streaming/StreamingPlanResponse"; import renderThinkingState from "./streaming/StreamingPlanState"; import ContentNotFound from "../NotFound/ContentNotFound"; import PlanChatBody from "./PlanChatBody"; +import renderBufferMessage from "./streaming/StreamingBufferMessage"; interface SimplifiedPlanChatProps extends PlanChatProps { onPlanReceived?: (planData: MPlanData) => void; initialTask?: string; planApprovalRequest: MPlanData | null; waitingForPlan: boolean; messagesContainerRef: React.RefObject; + streamingMessageBuffer: string; + agentMessages: any[]; } const PlanChat: React.FC = ({ @@ -50,7 +53,9 @@ const PlanChat: React.FC = ({ initialTask, planApprovalRequest, waitingForPlan, - messagesContainerRef + messagesContainerRef, + streamingMessageBuffer, + agentMessages }) => { const navigate = useNavigate(); @@ -154,6 +159,9 @@ const PlanChat: React.FC = ({ {/* Plan response with all information */} {renderPlanResponse(planApprovalRequest, handleApprovePlan, handleRejectPlan, processingApproval, showApprovalButtons)} + + {/* Streaming plan updates */} + {renderBufferMessage(streamingMessageBuffer)} {/* Chat Input - only show if no plan is waiting for approval */} diff --git a/src/frontend/src/components/content/PlanChatBody.tsx b/src/frontend/src/components/content/PlanChatBody.tsx index 8911353ff..b540c89fa 100644 --- a/src/frontend/src/components/content/PlanChatBody.tsx +++ b/src/frontend/src/components/content/PlanChatBody.tsx @@ -15,7 +15,6 @@ const PlanChatBody: React.FC = ({ OnChatSubmit, showChatInput, waitingForPlan - }) => { if (!showChatInput) { return null; diff --git a/src/frontend/src/components/content/streaming/StreamingBufferMessage.tsx b/src/frontend/src/components/content/streaming/StreamingBufferMessage.tsx new file mode 100644 index 000000000..f0e897580 --- /dev/null +++ b/src/frontend/src/components/content/streaming/StreamingBufferMessage.tsx @@ -0,0 +1,32 @@ +import { + Accordion, + AccordionItem, + AccordionHeader, + AccordionPanel, +} from '@fluentui/react-components'; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypePrism from "rehype-prism"; +import { useState } from "react"; +// Render AI thinking/planning state +const renderBufferMessage = (streamingMessageBuffer: string) => { + if (!streamingMessageBuffer || streamingMessageBuffer.trim() === "") return null; + return ( + + + + + + {streamingMessageBuffer} + + + + + + ); +}; + +export default renderBufferMessage; \ No newline at end of file diff --git a/src/frontend/src/pages/PlanPage.tsx b/src/frontend/src/pages/PlanPage.tsx index dc8b85a24..d39c52f37 100644 --- a/src/frontend/src/pages/PlanPage.tsx +++ b/src/frontend/src/pages/PlanPage.tsx @@ -53,10 +53,11 @@ const PlanPage: React.FC = () => { // WebSocket connection state const [wsConnected, setWsConnected] = useState(false); const [streamingMessages, setStreamingMessages] = useState([]); - + const [streamingMessageBuffer, setStreamingMessageBuffer] = useState(""); // RAI Error state const [raiError, setRAIError] = useState(null); + const [agentMessages, setAgentMessages] = useState([]); // Team config state const [teamConfig, setTeamConfig] = useState(null); const [loadingTeamConfig, setLoadingTeamConfig] = useState(true); @@ -72,7 +73,11 @@ const PlanPage: React.FC = () => { const scrollToBottom = useCallback(() => { setTimeout(() => { if (messagesContainerRef.current) { - messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight; + //messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight; + messagesContainerRef.current?.scrollTo({ + top: messagesContainerRef.current.scrollHeight, + behavior: "smooth", + }); } }, 100); }, []); @@ -116,6 +121,29 @@ const PlanPage: React.FC = () => { return () => unsubscribe(); }, [scrollToBottom]); //onPlanReceived, scrollToBottom + + + useEffect(() => { + const unsubscribe = webSocketService.on(WebsocketMessageType.AGENT_MESSAGE_STREAMING, (streamingMessage: any) => { + // console.log('📋 Streaming Message', streamingMessage); + setStreamingMessageBuffer(prev => prev + streamingMessage.data.content); + scrollToBottom(); + + }); + + return () => unsubscribe(); + }, [scrollToBottom]); //onPlanReceived, scrollToBottom + + useEffect(() => { + const unsubscribe = webSocketService.on(WebsocketMessageType.AGENT_MESSAGE, (agentMessage: any) => { + console.log('📋 Agent Message', agentMessage); + setAgentMessages(prev => [...prev, agentMessage]); + scrollToBottom(); + }); + + return () => unsubscribe(); + }, [scrollToBottom]); //onPlanReceived, scrollToBottom + // Loading message rotation effect useEffect(() => { let interval: NodeJS.Timeout; @@ -429,19 +457,6 @@ const PlanPage: React.FC = () => { - {/* Show RAI error if present */} - {raiError && ( -
- { - setRAIError(null); - }} - onDismiss={() => setRAIError(null)} - /> -
- )} - { planApprovalRequest={planApprovalRequest} waitingForPlan={waitingForPlan} messagesContainerRef={messagesContainerRef} + streamingMessageBuffer={streamingMessageBuffer} + agentMessages={agentMessages} + /> )} diff --git a/src/frontend/src/services/WebSocketService.tsx b/src/frontend/src/services/WebSocketService.tsx index 553bb5be1..819ca62db 100644 --- a/src/frontend/src/services/WebSocketService.tsx +++ b/src/frontend/src/services/WebSocketService.tsx @@ -165,13 +165,14 @@ class WebSocketService { } private handleMessage(message: StreamMessage): void { - console.log('WebSocket message received:', message); + const currentPlanIds = Array.from(this.planSubscriptions); const firstPlanId = currentPlanIds[0]; switch (message.type) { case WebsocketMessageType.PLAN_APPROVAL_REQUEST: { console.log("enter plan approval request"); + console.log('WebSocket message received:', message); const parsedData = PlanDataService.parsePlanApprovalRequest(message.data); if (parsedData) { const structuredMessage: ParsedPlanApprovalRequest = { @@ -189,44 +190,18 @@ class WebSocketService { case WebsocketMessageType.AGENT_MESSAGE: { if (message.data) { - const transformed: StreamMessage = { - ...message, - data: { - plan_id: firstPlanId, - agent_name: message.data.agent_name || 'Unknown Agent', - content: message.data.content || '', - message_type: 'thinking', - status: 'in_progress', - timestamp: Date.now() / 1000 - } - }; + console.log('WebSocket message received:', message); + const transformed = PlanDataService.parseAgentMessage(message); this.emit(WebsocketMessageType.AGENT_MESSAGE, transformed); - } else { - this.emit(WebsocketMessageType.AGENT_MESSAGE, message); + } break; } case WebsocketMessageType.AGENT_MESSAGE_STREAMING: { if (message.data) { - const isFinal = !!message.data.is_final; - const transformed: StreamMessage = { - type: WebsocketMessageType.AGENT_MESSAGE, - data: { - plan_id: message.data.plan_id || firstPlanId, - agent_name: message.data.agent_name || 'Unknown Agent', - content: message.data.content || '', - message_type: isFinal ? 'result' : 'thinking', - status: isFinal ? 'completed' : 'in_progress', - timestamp: Date.now() / 1000, - is_final: isFinal - } - }; - if (!transformed.data.plan_id) { - console.warn('Streaming message missing plan_id and no subscription context.'); - break; - } - this.emit(WebsocketMessageType.AGENT_MESSAGE, transformed); + const streamedMessage = PlanDataService.parseAgentMessageStreaming(message); + this.emit(WebsocketMessageType.AGENT_MESSAGE_STREAMING, streamedMessage); } break; } From 123a42150d3f91808dd06104d8586c2bef693181 Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Sun, 7 Sep 2025 21:31:49 -0400 Subject: [PATCH 16/16] Add agent message streaming to plan chat Introduces support for streaming agent messages in the plan chat by adding the AgentMessageData model, a new StreamingAgentMessage renderer, and updating PlanChat and PlanPage to handle and display agent messages. This enhances visibility into agent activity during plan generation. --- .../src/components/content/PlanChat.tsx | 7 +++-- .../streaming/StreamingAgentMessage.tsx | 30 +++++++++++++++++++ .../streaming/StreamingBufferMessage.tsx | 2 +- src/frontend/src/models/agentMessage.tsx | 9 ++++++ src/frontend/src/pages/PlanPage.tsx | 7 +++-- 5 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 src/frontend/src/components/content/streaming/StreamingAgentMessage.tsx diff --git a/src/frontend/src/components/content/PlanChat.tsx b/src/frontend/src/components/content/PlanChat.tsx index 319cfec36..e80d37550 100644 --- a/src/frontend/src/components/content/PlanChat.tsx +++ b/src/frontend/src/components/content/PlanChat.tsx @@ -24,7 +24,7 @@ import ChatInput from "../../coral/modules/ChatInput"; import InlineToaster, { useInlineToaster, } from "../toast/InlineToaster"; -import { WebsocketMessageType } from "@/models"; +import { AgentMessageData, WebsocketMessageType } from "@/models"; import getUserPlan from "./streaming/StreamingUserPlan"; import renderUserPlanMessage from "./streaming/StreamingUserPlanMessage"; import renderPlanResponse from "./streaming/StreamingPlanResponse"; @@ -32,6 +32,7 @@ import renderThinkingState from "./streaming/StreamingPlanState"; import ContentNotFound from "../NotFound/ContentNotFound"; import PlanChatBody from "./PlanChatBody"; import renderBufferMessage from "./streaming/StreamingBufferMessage"; +import renderAgentMessages from "./streaming/StreamingAgentMessage"; interface SimplifiedPlanChatProps extends PlanChatProps { onPlanReceived?: (planData: MPlanData) => void; initialTask?: string; @@ -39,7 +40,7 @@ interface SimplifiedPlanChatProps extends PlanChatProps { waitingForPlan: boolean; messagesContainerRef: React.RefObject; streamingMessageBuffer: string; - agentMessages: any[]; + agentMessages: AgentMessageData[]; } const PlanChat: React.FC = ({ @@ -159,6 +160,8 @@ const PlanChat: React.FC = ({ {/* Plan response with all information */} {renderPlanResponse(planApprovalRequest, handleApprovePlan, handleRejectPlan, processingApproval, showApprovalButtons)} + {renderAgentMessages(agentMessages)} + {/* Streaming plan updates */} {renderBufferMessage(streamingMessageBuffer)} diff --git a/src/frontend/src/components/content/streaming/StreamingAgentMessage.tsx b/src/frontend/src/components/content/streaming/StreamingAgentMessage.tsx new file mode 100644 index 000000000..398eaf8b2 --- /dev/null +++ b/src/frontend/src/components/content/streaming/StreamingAgentMessage.tsx @@ -0,0 +1,30 @@ +import { AgentMessageData } from "@/models"; + +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypePrism from "rehype-prism"; +// Render AI thinking/planning state +const renderAgentMessages = (agentMessages: AgentMessageData[]) => { + if (!agentMessages || agentMessages.length === 0) return null; + + return ( +
+ {agentMessages.map((msg, index) => { + const trimmed = msg.raw_content?.trim(); + if (!trimmed) return null; // skip if empty, null, or whitespace + return ( +
+ {msg.agent}: + + {trimmed} + +
+ ); + })} +
+ ); +}; +export default renderAgentMessages; \ No newline at end of file diff --git a/src/frontend/src/components/content/streaming/StreamingBufferMessage.tsx b/src/frontend/src/components/content/streaming/StreamingBufferMessage.tsx index f0e897580..8a94954e5 100644 --- a/src/frontend/src/components/content/streaming/StreamingBufferMessage.tsx +++ b/src/frontend/src/components/content/streaming/StreamingBufferMessage.tsx @@ -7,7 +7,7 @@ import { import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypePrism from "rehype-prism"; -import { useState } from "react"; + // Render AI thinking/planning state const renderBufferMessage = (streamingMessageBuffer: string) => { if (!streamingMessageBuffer || streamingMessageBuffer.trim() === "") return null; diff --git a/src/frontend/src/models/agentMessage.tsx b/src/frontend/src/models/agentMessage.tsx index 9e616345f..7f5aba96d 100644 --- a/src/frontend/src/models/agentMessage.tsx +++ b/src/frontend/src/models/agentMessage.tsx @@ -17,3 +17,12 @@ export interface AgentMessage extends BaseModel { /** Optional step identifier associated with the message */ step_id?: string; } + +export interface AgentMessageData { + agent: string; + timestamp: number; + steps: any[]; // intentionally always empty + next_steps: []; // intentionally always empty + raw_content: string; + raw_data: string; +} \ No newline at end of file diff --git a/src/frontend/src/pages/PlanPage.tsx b/src/frontend/src/pages/PlanPage.tsx index d39c52f37..8880ca9c3 100644 --- a/src/frontend/src/pages/PlanPage.tsx +++ b/src/frontend/src/pages/PlanPage.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState, useMemo } from "react" import { useParams, useNavigate } from "react-router-dom"; import { Spinner, Text } from "@fluentui/react-components"; import { PlanDataService } from "../services/PlanDataService"; -import { ProcessedPlanData, PlanWithSteps, WebsocketMessageType, MPlanData } from "../models"; +import { ProcessedPlanData, PlanWithSteps, WebsocketMessageType, MPlanData, AgentMessageData } from "../models"; import PlanChat from "../components/content/PlanChat"; import PlanPanelRight from "../components/content/PlanPanelRight"; import PlanPanelLeft from "../components/content/PlanPanelLeft"; @@ -57,7 +57,7 @@ const PlanPage: React.FC = () => { // RAI Error state const [raiError, setRAIError] = useState(null); - const [agentMessages, setAgentMessages] = useState([]); + const [agentMessages, setAgentMessages] = useState([]); // Team config state const [teamConfig, setTeamConfig] = useState(null); const [loadingTeamConfig, setLoadingTeamConfig] = useState(true); @@ -137,7 +137,8 @@ const PlanPage: React.FC = () => { useEffect(() => { const unsubscribe = webSocketService.on(WebsocketMessageType.AGENT_MESSAGE, (agentMessage: any) => { console.log('📋 Agent Message', agentMessage); - setAgentMessages(prev => [...prev, agentMessage]); + const agentMessageData = agentMessage.data as AgentMessageData; + setAgentMessages(prev => [...prev, agentMessageData]); scrollToBottom(); });