mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-27 09:13:07 +08:00
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
This commit is contained in:
@@ -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<unknown>(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(() => {
|
||||
|
||||
@@ -33,16 +33,18 @@ interface ScrollToBottomButtonProps {
|
||||
|
||||
function ScrollToBottomButton({ onClick, className }: ScrollToBottomButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={cn('absolute bottom-4 right-4 shadow-lg', className)}
|
||||
onClick={onClick}
|
||||
title="Scroll to bottom"
|
||||
>
|
||||
<ArrowDownToLine className="h-4 w-4 mr-1" />
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
<div className="sticky bottom-4 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={cn('shadow-lg', className)}
|
||||
onClick={onClick}
|
||||
title="Scroll to bottom"
|
||||
>
|
||||
<ArrowDownToLine className="h-4 w-4 mr-1" />
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Record<string, number>>({});
|
||||
|
||||
// Track last processed WebSocket message to prevent duplicate processing
|
||||
const lastProcessedMsgRef = useRef<unknown>(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(() => {
|
||||
|
||||
@@ -125,15 +125,17 @@ export function StreamingOutput({
|
||||
|
||||
{/* Scroll to bottom button */}
|
||||
{isUserScrolling && outputs.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="absolute bottom-3 right-3"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ArrowDownToLine className="h-4 w-4 mr-1" />
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
<div className="sticky bottom-3 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="shadow-lg"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ArrowDownToLine className="h-4 w-4 mr-1" />
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
202
ccw/frontend/src/hooks/useCliStreamWebSocket.ts
Normal file
202
ccw/frontend/src/hooks/useCliStreamWebSocket.ts
Normal file
@@ -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;
|
||||
@@ -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<unknown>(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<Set<string>>(new Set());
|
||||
|
||||
Reference in New Issue
Block a user