From b2c1288dabcfe85fd5e106667d861fd24ba3e74d Mon Sep 17 00:00:00 2001 From: catlog22 Date: Tue, 24 Feb 2026 09:40:43 +0800 Subject: [PATCH] fix(cli-viewer): resolve duplicate CLI output and sticky scroll button issues - Create centralized useCliStreamWebSocket hook with module-level message tracking to ensure each WebSocket message is processed only once globally - Replace position:absolute with position:sticky + flexbox wrapper for scroll-to-bottom buttons to fix viewport positioning - Remove duplicate WebSocket handling from CliViewerPage, CliStreamMonitorLegacy, and CliStreamMonitorNew components Fixes: - CLI output no longer duplicated when multiple components subscribe to the same WebSocket feed - Scroll-to-bottom button now stays fixed at bottom-right corner of viewport instead of scrolling with content --- .../CliStreamMonitor/CliStreamMonitorNew.tsx | 152 +------------ .../CliStreamMonitor/MonitorBody/index.tsx | 22 +- .../shared/CliStreamMonitorLegacy.tsx | 140 +----------- .../src/components/shared/StreamingOutput.tsx | 20 +- .../src/hooks/useCliStreamWebSocket.ts | 202 ++++++++++++++++++ ccw/frontend/src/pages/CliViewerPage.tsx | 151 +------------ 6 files changed, 243 insertions(+), 444 deletions(-) create mode 100644 ccw/frontend/src/hooks/useCliStreamWebSocket.ts diff --git a/ccw/frontend/src/components/shared/CliStreamMonitor/CliStreamMonitorNew.tsx b/ccw/frontend/src/components/shared/CliStreamMonitor/CliStreamMonitorNew.tsx index a853a2b9..2ad1e8eb 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitor/CliStreamMonitorNew.tsx +++ b/ccw/frontend/src/components/shared/CliStreamMonitor/CliStreamMonitorNew.tsx @@ -3,15 +3,15 @@ // ======================================== // Redesigned CLI streaming monitor with smart parsing and message-based layout -import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { useIntl } from 'react-intl'; import { Terminal, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useCliStreamStore, type CliOutputLine } from '@/stores/cliStreamStore'; -import { useNotificationStore, selectWsLastMessage } from '@/stores'; -import { useActiveCliExecutions, useInvalidateActiveCliExecutions } from '@/hooks/useActiveCliExecutions'; +import { useActiveCliExecutions } from '@/hooks/useActiveCliExecutions'; +import { useCliStreamWebSocket } from '@/hooks/useCliStreamWebSocket'; // New layout components import { MonitorHeader } from './MonitorHeader'; @@ -24,37 +24,8 @@ import { ErrorMessage, } from './messages'; -// ========== Types for CLI WebSocket Messages ========== - -interface CliStreamStartedPayload { - executionId: string; - tool: string; - mode: string; - timestamp: string; -} - -interface CliStreamOutputPayload { - executionId: string; - chunkType: string; - data: unknown; - unit?: { - content: unknown; - type?: string; - }; -} - -interface CliStreamCompletedPayload { - executionId: string; - success: boolean; - duration?: number; - timestamp: string; -} - -interface CliStreamErrorPayload { - executionId: string; - error?: string; - timestamp: string; -} +// ========== Types ========== +// WebSocket message types are now handled centrally in useCliStreamWebSocket hook // ========== Message Type Detection ========== @@ -179,20 +150,6 @@ function parseOutputToMessages( return messages; } -// ========== Helper Functions ========== - -function formatDuration(ms: number): string { - if (ms < 1000) return `${ms}ms`; - const seconds = Math.floor(ms / 1000); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; - const hours = Math.floor(minutes / 60); - const remainingMinutes = minutes % 60; - return `${hours}h ${remainingMinutes}m`; -} - // ========== Component ========== export interface CliStreamMonitorNewProps { @@ -215,104 +172,9 @@ export function CliStreamMonitorNew({ isOpen, onClose }: CliStreamMonitorNewProp // Active execution sync const { isLoading: isSyncing, refetch } = useActiveCliExecutions(isOpen); - const invalidateActive = useInvalidateActiveCliExecutions(); - // WebSocket last message - const lastMessage = useNotificationStore(selectWsLastMessage); - - // Track last processed WebSocket message to prevent duplicate processing - const lastProcessedMsgRef = useRef(null); - - // Handle WebSocket messages (same as original) - useEffect(() => { - // Skip if no message or same message already processed (prevents React strict mode double-execution) - if (!lastMessage || lastMessage === lastProcessedMsgRef.current) return; - lastProcessedMsgRef.current = lastMessage; - - const { type, payload } = lastMessage; - - if (type === 'CLI_STARTED') { - const p = payload as CliStreamStartedPayload; - const startTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now(); - useCliStreamStore.getState().upsertExecution(p.executionId, { - tool: p.tool || 'cli', - mode: p.mode || 'analysis', - status: 'running', - startTime, - output: [ - { - type: 'system', - content: `[${new Date(startTime).toLocaleTimeString()}] CLI execution started: ${p.tool} (${p.mode} mode)`, - timestamp: startTime - } - ] - }); - invalidateActive(); - } else if (type === 'CLI_OUTPUT') { - const p = payload as CliStreamOutputPayload; - const unitContent = p.unit?.content ?? p.data; - const unitType = p.unit?.type || p.chunkType; - - let content: string; - if (unitType === 'tool_call' && typeof unitContent === 'object' && unitContent !== null) { - const toolCall = unitContent as { action?: string; toolName?: string; parameters?: unknown; status?: string; output?: string }; - if (toolCall.action === 'invoke') { - const params = toolCall.parameters ? JSON.stringify(toolCall.parameters) : ''; - content = `[Tool] ${toolCall.toolName}(${params})`; - } else if (toolCall.action === 'result') { - const status = toolCall.status || 'unknown'; - const output = toolCall.output ? `: ${toolCall.output.substring(0, 200)}${toolCall.output.length > 200 ? '...' : ''}` : ''; - content = `[Tool Result] ${status}${output}`; - } else { - content = JSON.stringify(unitContent); - } - } else { - content = typeof unitContent === 'string' ? unitContent : JSON.stringify(unitContent); - } - - const lines = content.split('\n'); - const addOutput = useCliStreamStore.getState().addOutput; - lines.forEach(line => { - if (line.trim() || lines.length === 1) { - addOutput(p.executionId, { - type: (unitType as CliOutputLine['type']) || 'stdout', - content: line, - timestamp: Date.now() - }); - } - }); - } else if (type === 'CLI_COMPLETED') { - const p = payload as CliStreamCompletedPayload; - const endTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now(); - useCliStreamStore.getState().upsertExecution(p.executionId, { - status: p.success ? 'completed' : 'error', - endTime, - output: [ - { - type: 'system', - content: `[${new Date(endTime).toLocaleTimeString()}] CLI execution ${p.success ? 'completed successfully' : 'failed'}${p.duration ? ` (${formatDuration(p.duration)})` : ''}`, - timestamp: endTime - } - ] - }); - invalidateActive(); - } else if (type === 'CLI_ERROR') { - const p = payload as CliStreamErrorPayload; - const endTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now(); - useCliStreamStore.getState().upsertExecution(p.executionId, { - status: 'error', - endTime, - output: [ - { - type: 'stderr', - content: `[ERROR] ${p.error || 'Unknown error occurred'}`, - timestamp: endTime - } - ] - }); - invalidateActive(); - } - }, [lastMessage, invalidateActive]); + // CENTRALIZED WebSocket handler - processes each message only once globally + useCliStreamWebSocket(); // Get execution stats const executionStats = useMemo(() => { diff --git a/ccw/frontend/src/components/shared/CliStreamMonitor/MonitorBody/index.tsx b/ccw/frontend/src/components/shared/CliStreamMonitor/MonitorBody/index.tsx index 2e66b0bc..bb4f93af 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitor/MonitorBody/index.tsx +++ b/ccw/frontend/src/components/shared/CliStreamMonitor/MonitorBody/index.tsx @@ -33,16 +33,18 @@ interface ScrollToBottomButtonProps { function ScrollToBottomButton({ onClick, className }: ScrollToBottomButtonProps) { return ( - +
+ +
); } diff --git a/ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx b/ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx index 1580473f..848f603a 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx +++ b/ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx @@ -24,45 +24,16 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/Tabs'; import { Badge } from '@/components/ui/Badge'; import { LogBlockList } from '@/components/shared/LogBlock'; import { useCliStreamStore, type CliOutputLine } from '@/stores/cliStreamStore'; -import { useNotificationStore, selectWsLastMessage } from '@/stores'; -import { useActiveCliExecutions, useInvalidateActiveCliExecutions } from '@/hooks/useActiveCliExecutions'; +import { useActiveCliExecutions } from '@/hooks/useActiveCliExecutions'; +import { useCliStreamWebSocket } from '@/hooks/useCliStreamWebSocket'; // New components for Tab + JSON Cards import { ExecutionTab } from './CliStreamMonitor/components/ExecutionTab'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -// ========== Types for CLI WebSocket Messages ========== - -interface CliStreamStartedPayload { - executionId: string; - tool: string; - mode: string; - timestamp: string; -} - -interface CliStreamOutputPayload { - executionId: string; - chunkType: string; - data: unknown; - unit?: { - content: unknown; - type?: string; - }; -} - -interface CliStreamCompletedPayload { - executionId: string; - success: boolean; - duration?: number; - timestamp: string; -} - -interface CliStreamErrorPayload { - executionId: string; - error?: string; - timestamp: string; -} +// ========== Types ========== +// WebSocket message types are now handled centrally in useCliStreamWebSocket hook // ========== Helper Functions ========== @@ -209,9 +180,6 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) { // Track last output length to detect new output const lastOutputLengthRef = useRef>({}); - // Track last processed WebSocket message to prevent duplicate processing - const lastProcessedMsgRef = useRef(null); - // Store state const executions = useCliStreamStore((state) => state.executions); const currentExecutionId = useCliStreamStore((state) => state.currentExecutionId); @@ -221,105 +189,9 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) { // Active execution sync const { isLoading: isSyncing, refetch } = useActiveCliExecutions(isOpen); - const invalidateActive = useInvalidateActiveCliExecutions(); - // WebSocket last message from notification store - const lastMessage = useNotificationStore(selectWsLastMessage); - - // Handle WebSocket messages for CLI stream - useEffect(() => { - // Skip if no message or same message already processed (prevents React strict mode double-execution) - if (!lastMessage || lastMessage === lastProcessedMsgRef.current) return; - lastProcessedMsgRef.current = lastMessage; - - const { type, payload } = lastMessage; - - if (type === 'CLI_STARTED') { - const p = payload as CliStreamStartedPayload; - const startTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now(); - useCliStreamStore.getState().upsertExecution(p.executionId, { - tool: p.tool || 'cli', - mode: p.mode || 'analysis', - status: 'running', - startTime, - output: [ - { - type: 'system', - content: `[${new Date(startTime).toLocaleTimeString()}] CLI execution started: ${p.tool} (${p.mode} mode)`, - timestamp: startTime - } - ] - }); - // Set as current if none selected - if (!currentExecutionId) { - setCurrentExecution(p.executionId); - } - invalidateActive(); - } else if (type === 'CLI_OUTPUT') { - const p = payload as CliStreamOutputPayload; - const unitContent = p.unit?.content ?? p.data; - const unitType = p.unit?.type || p.chunkType; - - let content: string; - if (unitType === 'tool_call' && typeof unitContent === 'object' && unitContent !== null) { - const toolCall = unitContent as { action?: string; toolName?: string; parameters?: unknown; status?: string; output?: string }; - if (toolCall.action === 'invoke') { - const params = toolCall.parameters ? JSON.stringify(toolCall.parameters) : ''; - content = `[Tool] ${toolCall.toolName}(${params})`; - } else if (toolCall.action === 'result') { - const status = toolCall.status || 'unknown'; - const output = toolCall.output ? `: ${toolCall.output.substring(0, 200)}${toolCall.output.length > 200 ? '...' : ''}` : ''; - content = `[Tool Result] ${status}${output}`; - } else { - content = JSON.stringify(unitContent); - } - } else { - content = typeof unitContent === 'string' ? unitContent : JSON.stringify(unitContent); - } - - const lines = content.split('\n'); - const addOutput = useCliStreamStore.getState().addOutput; - lines.forEach(line => { - if (line.trim() || lines.length === 1) { - addOutput(p.executionId, { - type: (unitType as CliOutputLine['type']) || 'stdout', - content: line, - timestamp: Date.now() - }); - } - }); - } else if (type === 'CLI_COMPLETED') { - const p = payload as CliStreamCompletedPayload; - const endTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now(); - useCliStreamStore.getState().upsertExecution(p.executionId, { - status: p.success ? 'completed' : 'error', - endTime, - output: [ - { - type: 'system', - content: `[${new Date(endTime).toLocaleTimeString()}] CLI execution ${p.success ? 'completed successfully' : 'failed'}${p.duration ? ` (${formatDuration(p.duration)})` : ''}`, - timestamp: endTime - } - ] - }); - invalidateActive(); - } else if (type === 'CLI_ERROR') { - const p = payload as CliStreamErrorPayload; - const endTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now(); - useCliStreamStore.getState().upsertExecution(p.executionId, { - status: 'error', - endTime, - output: [ - { - type: 'stderr', - content: `[ERROR] ${p.error || 'Unknown error occurred'}`, - timestamp: endTime - } - ] - }); - invalidateActive(); - } - }, [lastMessage, invalidateActive]); + // CENTRALIZED WebSocket handler - processes each message only once globally + useCliStreamWebSocket(); // Auto-scroll to bottom when new output arrives (optimized - only scroll when output length changes) useEffect(() => { diff --git a/ccw/frontend/src/components/shared/StreamingOutput.tsx b/ccw/frontend/src/components/shared/StreamingOutput.tsx index 7949e5b1..a8317e27 100644 --- a/ccw/frontend/src/components/shared/StreamingOutput.tsx +++ b/ccw/frontend/src/components/shared/StreamingOutput.tsx @@ -125,15 +125,17 @@ export function StreamingOutput({ {/* Scroll to bottom button */} {isUserScrolling && outputs.length > 0 && ( - +
+ +
)} ); diff --git a/ccw/frontend/src/hooks/useCliStreamWebSocket.ts b/ccw/frontend/src/hooks/useCliStreamWebSocket.ts new file mode 100644 index 00000000..3329c10f --- /dev/null +++ b/ccw/frontend/src/hooks/useCliStreamWebSocket.ts @@ -0,0 +1,202 @@ +// ======================================== +// useCliStreamWebSocket Hook +// ======================================== +// Centralized WebSocket message handler for CLI stream +// Ensures each message is processed ONLY ONCE across all components + +import { useEffect, useRef } from 'react'; +import { useNotificationStore, selectWsLastMessage } from '@/stores'; +import { useCliStreamStore, type CliOutputLine } from '@/stores/cliStreamStore'; +import { useInvalidateActiveCliExecutions } from '@/hooks/useActiveCliExecutions'; + +// ========== Module-level Message Tracking ========== +// CRITICAL: This MUST be at module level to ensure single instance across all component mounts +// Prevents duplicate message processing when multiple components subscribe to WebSocket +let globalLastProcessedMsg: unknown = null; + +// ========== Types for CLI WebSocket Messages ========== + +interface CliStreamStartedPayload { + executionId: string; + tool: string; + mode: string; + timestamp: string; +} + +interface CliStreamOutputPayload { + executionId: string; + chunkType: string; + data: unknown; + unit?: { + content: unknown; + type?: string; + }; +} + +interface CliStreamCompletedPayload { + executionId: string; + success: boolean; + duration?: number; + timestamp: string; +} + +interface CliStreamErrorPayload { + executionId: string; + error?: string; + timestamp: string; +} + +// ========== Helper Functions ========== + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${hours}h ${remainingMinutes}m`; +} + +// ========== Hook ========== + +/** + * Centralized WebSocket handler for CLI stream messages + * + * IMPORTANT: This hook uses module-level state to ensure each WebSocket message + * is processed exactly ONCE, even when multiple components are mounted. + * + * Components should simply consume data from useCliStreamStore - no need to + * handle WebSocket messages individually. + * + * @example + * ```tsx + * // In your app root or layout component + * useCliStreamWebSocket(); + * + * // In any component that needs CLI output + * const executions = useCliStreamStore(state => state.executions); + * ``` + */ +export function useCliStreamWebSocket(): void { + const lastMessage = useNotificationStore(selectWsLastMessage); + const invalidateActive = useInvalidateActiveCliExecutions(); + + // Ref to track if this hook instance is active (for cleanup) + const isActiveRef = useRef(true); + + useEffect(() => { + isActiveRef.current = true; + + return () => { + isActiveRef.current = false; + }; + }, []); + + useEffect(() => { + // Skip if no message or already processed GLOBALLY + if (!lastMessage || lastMessage === globalLastProcessedMsg) return; + + // Mark as processed immediately at module level + globalLastProcessedMsg = lastMessage; + + // Check if hook is still active (component not unmounted) + if (!isActiveRef.current) return; + + const { type, payload } = lastMessage; + + if (type === 'CLI_STARTED') { + const p = payload as CliStreamStartedPayload; + const startTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now(); + useCliStreamStore.getState().upsertExecution(p.executionId, { + tool: p.tool || 'cli', + mode: p.mode || 'analysis', + status: 'running', + startTime, + output: [ + { + type: 'system', + content: `[${new Date(startTime).toLocaleTimeString()}] CLI execution started: ${p.tool} (${p.mode} mode)`, + timestamp: startTime + } + ] + }); + invalidateActive(); + } else if (type === 'CLI_OUTPUT') { + const p = payload as CliStreamOutputPayload; + const unitContent = p.unit?.content ?? p.data; + const unitType = p.unit?.type || p.chunkType; + + let content: string; + if (unitType === 'tool_call' && typeof unitContent === 'object' && unitContent !== null) { + const toolCall = unitContent as { action?: string; toolName?: string; parameters?: unknown; status?: string; output?: string }; + if (toolCall.action === 'invoke') { + const params = toolCall.parameters ? JSON.stringify(toolCall.parameters) : ''; + content = `[Tool] ${toolCall.toolName}(${params})`; + } else if (toolCall.action === 'result') { + const status = toolCall.status || 'unknown'; + const output = toolCall.output ? `: ${toolCall.output.substring(0, 200)}${toolCall.output.length > 200 ? '...' : ''}` : ''; + content = `[Tool Result] ${status}${output}`; + } else { + content = JSON.stringify(unitContent); + } + } else { + content = typeof unitContent === 'string' ? unitContent : JSON.stringify(unitContent); + } + + const lines = content.split('\n'); + const addOutput = useCliStreamStore.getState().addOutput; + lines.forEach(line => { + if (line.trim() || lines.length === 1) { + addOutput(p.executionId, { + type: (unitType as CliOutputLine['type']) || 'stdout', + content: line, + timestamp: Date.now() + }); + } + }); + } else if (type === 'CLI_COMPLETED') { + const p = payload as CliStreamCompletedPayload; + const endTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now(); + useCliStreamStore.getState().upsertExecution(p.executionId, { + status: p.success ? 'completed' : 'error', + endTime, + output: [ + { + type: 'system', + content: `[${new Date(endTime).toLocaleTimeString()}] CLI execution ${p.success ? 'completed successfully' : 'failed'}${p.duration ? ` (${formatDuration(p.duration)})` : ''}`, + timestamp: endTime + } + ] + }); + invalidateActive(); + } else if (type === 'CLI_ERROR') { + const p = payload as CliStreamErrorPayload; + const endTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now(); + useCliStreamStore.getState().upsertExecution(p.executionId, { + status: 'error', + endTime, + output: [ + { + type: 'stderr', + content: `[ERROR] ${p.error || 'Unknown error occurred'}`, + timestamp: endTime + } + ] + }); + invalidateActive(); + } + }, [lastMessage, invalidateActive]); +} + +/** + * Reset the global message tracker + * Useful for testing or when explicitly re-processing messages is needed + */ +export function resetCliStreamWebSocketTracker(): void { + globalLastProcessedMsg = null; +} + +export default useCliStreamWebSocket; diff --git a/ccw/frontend/src/pages/CliViewerPage.tsx b/ccw/frontend/src/pages/CliViewerPage.tsx index bf8baeea..aa64cc1f 100644 --- a/ccw/frontend/src/pages/CliViewerPage.tsx +++ b/ccw/frontend/src/pages/CliViewerPage.tsx @@ -15,44 +15,9 @@ import { useFocusedPaneId, type AllotmentLayout, } from '@/stores/viewerStore'; -import { useCliStreamStore, type CliOutputLine } from '@/stores/cliStreamStore'; -import { useNotificationStore, selectWsLastMessage } from '@/stores'; -import { useActiveCliExecutions, useInvalidateActiveCliExecutions } from '@/hooks/useActiveCliExecutions'; - -// ======================================== -// Types -// ======================================== - -// CLI WebSocket message types (matching CliStreamMonitorLegacy) -interface CliStreamStartedPayload { - executionId: string; - tool: string; - mode: string; - timestamp: string; -} - -interface CliStreamOutputPayload { - executionId: string; - chunkType: string; - data: unknown; - unit?: { - content: unknown; - type?: string; - }; -} - -interface CliStreamCompletedPayload { - executionId: string; - success: boolean; - duration?: number; - timestamp: string; -} - -interface CliStreamErrorPayload { - executionId: string; - error?: string; - timestamp: string; -} +import { useCliStreamStore } from '@/stores/cliStreamStore'; +import { useActiveCliExecutions } from '@/hooks/useActiveCliExecutions'; +import { useCliStreamWebSocket } from '@/hooks/useCliStreamWebSocket'; // ======================================== // Constants @@ -64,18 +29,6 @@ const DEFAULT_LAYOUT = 'split-h' as const; // Helper Functions // ======================================== -function formatDuration(ms: number): string { - if (ms < 1000) return `${ms}ms`; - const seconds = Math.floor(ms / 1000); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; - const hours = Math.floor(minutes / 60); - const remainingMinutes = minutes % 60; - return `${hours}h ${remainingMinutes}m`; -} - /** * Count total panes in layout */ @@ -113,105 +66,11 @@ export function CliViewerPage() { // CLI Stream Store hooks const executions = useCliStreamStore((state) => state.executions); - // Track last processed WebSocket message to prevent duplicate processing - const lastProcessedMsgRef = useRef(null); - - // WebSocket last message from notification store - const lastMessage = useNotificationStore(selectWsLastMessage); - // Active execution sync from server useActiveCliExecutions(true); - const invalidateActive = useInvalidateActiveCliExecutions(); - // Handle WebSocket messages for CLI stream (same logic as CliStreamMonitorLegacy) - useEffect(() => { - if (!lastMessage || lastMessage === lastProcessedMsgRef.current) return; - lastProcessedMsgRef.current = lastMessage; - - const { type, payload } = lastMessage; - - if (type === 'CLI_STARTED') { - const p = payload as CliStreamStartedPayload; - const startTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now(); - useCliStreamStore.getState().upsertExecution(p.executionId, { - tool: p.tool || 'cli', - mode: p.mode || 'analysis', - status: 'running', - startTime, - output: [ - { - type: 'system', - content: `[${new Date(startTime).toLocaleTimeString()}] CLI execution started: ${p.tool} (${p.mode} mode)`, - timestamp: startTime - } - ] - }); - invalidateActive(); - } else if (type === 'CLI_OUTPUT') { - const p = payload as CliStreamOutputPayload; - const unitContent = p.unit?.content ?? p.data; - const unitType = p.unit?.type || p.chunkType; - - let content: string; - if (unitType === 'tool_call' && typeof unitContent === 'object' && unitContent !== null) { - const toolCall = unitContent as { action?: string; toolName?: string; parameters?: unknown; status?: string; output?: string }; - if (toolCall.action === 'invoke') { - const params = toolCall.parameters ? JSON.stringify(toolCall.parameters) : ''; - content = `[Tool] ${toolCall.toolName}(${params})`; - } else if (toolCall.action === 'result') { - const status = toolCall.status || 'unknown'; - const output = toolCall.output ? `: ${toolCall.output.substring(0, 200)}${toolCall.output.length > 200 ? '...' : ''}` : ''; - content = `[Tool Result] ${status}${output}`; - } else { - content = JSON.stringify(unitContent); - } - } else { - content = typeof unitContent === 'string' ? unitContent : JSON.stringify(unitContent); - } - - const lines = content.split('\n'); - const addOutput = useCliStreamStore.getState().addOutput; - lines.forEach(line => { - if (line.trim() || lines.length === 1) { - addOutput(p.executionId, { - type: (unitType as CliOutputLine['type']) || 'stdout', - content: line, - timestamp: Date.now() - }); - } - }); - } else if (type === 'CLI_COMPLETED') { - const p = payload as CliStreamCompletedPayload; - const endTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now(); - useCliStreamStore.getState().upsertExecution(p.executionId, { - status: p.success ? 'completed' : 'error', - endTime, - output: [ - { - type: 'system', - content: `[${new Date(endTime).toLocaleTimeString()}] CLI execution ${p.success ? 'completed successfully' : 'failed'}${p.duration ? ` (${formatDuration(p.duration)})` : ''}`, - timestamp: endTime - } - ] - }); - invalidateActive(); - } else if (type === 'CLI_ERROR') { - const p = payload as CliStreamErrorPayload; - const endTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now(); - useCliStreamStore.getState().upsertExecution(p.executionId, { - status: 'error', - endTime, - output: [ - { - type: 'stderr', - content: `[ERROR] ${p.error || 'Unknown error occurred'}`, - timestamp: endTime - } - ] - }); - invalidateActive(); - } - }, [lastMessage, invalidateActive]); + // CENTRALIZED WebSocket handler - processes each message only ONCE globally + useCliStreamWebSocket(); // Auto-add new executions as tabs, distributing across available panes const addedExecutionsRef = useRef>(new Set());