diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index 3031c27a..68de5084 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -47,7 +47,7 @@ export const ModernFlowChatContainer: React.FC = ( const autoPinnedSessionIdRef = useRef(null); const virtualListRef = useRef(null); const { workspacePath } = useWorkspaceContext(); - const { isBtwSession, btwOrigin, btwParentTitle } = useFlowChatSessionRelationship(activeSession); + const { btwOrigin, btwParentTitle } = useFlowChatSessionRelationship(activeSession); const { exploreGroupStates, onExploreGroupToggle: handleExploreGroupToggle, diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx index 763dd2bf..b40e76f6 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -1040,11 +1040,17 @@ export const VirtualMessageList = forwardRef((_, ref) => resizeObserverRef.current.observe(resizeTarget); mutationObserverRef.current?.disconnect(); + let mutationPending = false; mutationObserverRef.current = new MutationObserver(() => { - scheduleHeightMeasure(2); - scheduleVisibleTurnMeasure(2); - schedulePinReservationReconcile(2); - scheduleFollowToLatestWithViewportState('mutation-observer'); + if (mutationPending) return; + mutationPending = true; + Promise.resolve().then(() => { + mutationPending = false; + scheduleHeightMeasure(2); + scheduleVisibleTurnMeasure(2); + schedulePinReservationReconcile(2); + scheduleFollowToLatestWithViewportState('mutation-observer'); + }); }); mutationObserverRef.current.observe(scrollerElement, { subtree: true, diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 05a2a5ce..836d2159 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -45,6 +45,7 @@ export class FlowChatManager { private context: FlowChatContext; private agentService: AgentService; private eventListenerInitialized = false; + private eventListenerCleanup: (() => void) | null = null; private constructor() { this.context = { @@ -159,7 +160,7 @@ export class FlowChatManager { return; } - await initializeEventListeners( + this.eventListenerCleanup = await initializeEventListeners( this.context, (sessionId, turnId, result) => this.handleTodoWriteResult(sessionId, turnId, result) ); @@ -167,6 +168,14 @@ export class FlowChatManager { this.eventListenerInitialized = true; } + public cleanupEventListeners(): void { + if (this.eventListenerCleanup) { + this.eventListenerCleanup(); + this.eventListenerCleanup = null; + this.eventListenerInitialized = false; + } + } + private processBatchedEvents(events: Array<{ key: string; payload: any }>): void { processBatchedEvents( this.context, diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 2dd257cb..71917623 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -137,16 +137,17 @@ export function mapBackendStateToFrontend(backendState: any): SessionExecutionSt /** * Initialize global event listeners + * Returns a cleanup function that removes all registered listeners */ export async function initializeEventListeners( context: FlowChatContext, onTodoWriteResult: (sessionId: string, turnId: string, result: any) => void -): Promise { +): Promise<() => void> { const { listen } = await import('@tauri-apps/api/event'); - await listen('backend-event-toolexecutionprogress', (event: any) => { + const unlistenProgress = await listen('backend-event-toolexecutionprogress', (event: any) => { handleToolExecutionProgress(event.payload); }); - await listen('backend-event-toolterminalready', (event: any) => { + const unlistenTerminalReady = await listen('backend-event-toolterminalready', (event: any) => { const eventData = (event.payload as any)?.value || event.payload; handleToolTerminalReady(eventData); }); @@ -206,6 +207,12 @@ export async function initializeEventListeners( }; await agenticEventListener.startListening(callbacks); + + return () => { + unlistenProgress(); + unlistenTerminalReady(); + agenticEventListener.stopListening(); + }; } /** @@ -407,6 +414,8 @@ function handleImageAnalysisStarted(context: FlowChatContext, event: ImageAnalys stateMachineManager.transition(sessionId, SessionExecutionEvent.START, { taskId: sessionId, dialogTurnId: tempTurnId, + }).catch(error => { + log.error('State machine transition failed on image analysis start', { sessionId, error }); }); log.info('Image analysis started: created temp turn for remote', { @@ -545,6 +554,8 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { stateMachineManager.transition(sessionId, SessionExecutionEvent.START, { taskId: sessionId, dialogTurnId: turnId, + }).catch(error => { + log.error('State machine transition failed on dialog turn start', { sessionId, error }); }); } return; @@ -605,6 +616,8 @@ function handleTextChunk(context: FlowChatContext, event: any): void { if (currentState === SessionExecutionState.PROCESSING) { stateMachineManager.transition(sessionId, SessionExecutionEvent.TEXT_CHUNK_RECEIVED, { content: text, + }).catch(error => { + log.error('State machine transition failed on text chunk', { sessionId, error }); }); } } @@ -781,6 +794,8 @@ function handleToolEvent( turnId, toolEvent }, onTodoWriteResult); + }).catch(error => { + log.error('Failed to load SubagentModule or route tool event', { sessionId, turnId, error }); }); } else { processToolEvent(context, sessionId, turnId, toolEvent, undefined, onTodoWriteResult); @@ -819,6 +834,8 @@ function handleModelRoundStart(context: FlowChatContext, event: any): void { if (currentState === SessionExecutionState.PROCESSING) { stateMachineManager.transition(sessionId, SessionExecutionEvent.MODEL_ROUND_START, { modelRoundId: roundId, + }).catch(error => { + log.error('State machine transition failed on model round start', { sessionId, error }); }); } @@ -1043,7 +1060,9 @@ function handleDialogTurnComplete( const currentState = stateMachineManager.getCurrentState(sessionId); if (currentState === SessionExecutionState.PROCESSING) { - stateMachineManager.transition(sessionId, SessionExecutionEvent.STREAM_COMPLETE); + stateMachineManager.transition(sessionId, SessionExecutionEvent.STREAM_COMPLETE).catch(error => { + log.error('State machine transition failed on stream complete', { sessionId, error }); + }); } else { log.debug('Skipping STREAM_COMPLETE transition', { currentState, sessionId }); } @@ -1138,8 +1157,12 @@ function handleDialogTurnFailed(context: FlowChatContext, event: any): void { if (currentState === SessionExecutionState.PROCESSING) { stateMachineManager.transition(sessionId, SessionExecutionEvent.ERROR_OCCURRED, { error: error || 'Execution failed' + }).catch(err => { + log.error('State machine transition failed on error occurred', { sessionId, error: err }); + }); + stateMachineManager.transition(sessionId, SessionExecutionEvent.RESET).catch(err => { + log.error('State machine transition failed on reset', { sessionId, error: err }); }); - stateMachineManager.transition(sessionId, SessionExecutionEvent.RESET); } notificationService.error(error || 'Execution failed', { @@ -1221,7 +1244,9 @@ function handleDialogTurnCancelled( // external source (mobile remote), the machine is still PROCESSING. const currentState = stateMachineManager.getCurrentState(sessionId); if (currentState === SessionExecutionState.PROCESSING) { - stateMachineManager.transition(sessionId, SessionExecutionEvent.STREAM_COMPLETE); + stateMachineManager.transition(sessionId, SessionExecutionEvent.STREAM_COMPLETE).catch(error => { + log.error('State machine transition failed on cancelled stream complete', { sessionId, error }); + }); } } diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 6a5ea74b..c76f28dc 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -82,7 +82,13 @@ export class FlowChatStore { this.state = newState; if (!this.silentMode) { - this.listeners.forEach(listener => listener(newState)); + this.listeners.forEach(listener => { + try { + listener(newState); + } catch (error) { + console.error('[FlowChatStore] Listener threw an error, skipping:', error); + } + }); } } @@ -104,7 +110,13 @@ export class FlowChatStore { * Manually notify all listeners (call after batch updates complete) */ public notifyListeners(): void { - this.listeners.forEach(listener => listener(this.state)); + this.listeners.forEach(listener => { + try { + listener(this.state); + } catch (error) { + console.error('[FlowChatStore] Listener threw an error during notifyListeners, skipping:', error); + } + }); } public beginSilentMode(): void { diff --git a/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts index 9f2eb5cd..a086b708 100644 --- a/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts @@ -79,7 +79,11 @@ export class TauriTransportAdapter implements ITransportAdapter { listen(event, (e) => { if (!isUnlistened) { - callback(e.payload); + try { + callback(e.payload); + } catch (error) { + log.error('Error in event listener callback', { event, error }); + } } }).then(fn => { if (isUnlistened) {