summaryrefslogblamecommitdiff
path: root/frontend/src/components/document/nodes/StepLogFeed.tsx
blob: 2f2f553febdd02c261eac5a185a87e07bdfe40ca (plain) (tree)




















































































































































































































                                                                                                            
                                             











































                                                                                           
                                                            

















                                                        
import React, { useEffect, useRef, useState, useCallback } from 'react';

interface StepLogFeedProps {
  taskId: string;
  stepName: string;
  stepStatus: string;
  onCollapse: () => void;
}

interface LogEntry {
  timestamp: string;
  content: string;
  type: 'stdout' | 'stderr' | 'system' | 'user';
}

/**
 * Live log feed for an expanded step row.
 * Connects via WebSocket to stream task output and allows
 * sending messages (comments) and interrupting the task.
 */
export function StepLogFeed({ taskId, stepName, stepStatus, onCollapse }: StepLogFeedProps) {
  const [logs, setLogs] = useState<LogEntry[]>([]);
  const [message, setMessage] = useState('');
  const [sending, setSending] = useState(false);
  const [connected, setConnected] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const logsEndRef = useRef<HTMLDivElement>(null);
  const wsRef = useRef<WebSocket | null>(null);
  const logContainerRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);

  const isActive = ['running', 'starting'].includes(stepStatus.toLowerCase());

  // Auto-scroll to bottom when new logs arrive
  useEffect(() => {
    logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [logs]);

  // Connect to WebSocket for live streaming
  useEffect(() => {
    if (!taskId) return;

    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
    const wsUrl = `${protocol}//${window.location.host}/api/v1/mesh/tasks/subscribe`;

    let ws: WebSocket;
    let reconnectTimer: ReturnType<typeof setTimeout>;
    let shouldReconnect = true;

    function connect() {
      try {
        ws = new WebSocket(wsUrl);
        wsRef.current = ws;

        ws.addEventListener('open', () => {
          setConnected(true);
          setError(null);
          // Subscribe to this specific task
          ws.send(JSON.stringify({ type: 'subscribe', taskId }));
        });

        ws.addEventListener('message', (evt) => {
          try {
            const data = JSON.parse(evt.data);
            // Handle different message formats from the backend
            if (data.taskId === taskId || data.task_id === taskId) {
              const entry: LogEntry = {
                timestamp: data.timestamp || new Date().toISOString(),
                content: data.content || data.output || data.message || JSON.stringify(data),
                type: data.type || data.stream || 'stdout',
              };
              setLogs(prev => [...prev, entry]);
            }
          } catch {
            // Non-JSON message, treat as raw log
            setLogs(prev => [...prev, {
              timestamp: new Date().toISOString(),
              content: evt.data,
              type: 'stdout',
            }]);
          }
        });

        ws.addEventListener('close', () => {
          setConnected(false);
          wsRef.current = null;
          if (shouldReconnect && isActive) {
            reconnectTimer = setTimeout(connect, 3000);
          }
        });

        ws.addEventListener('error', () => {
          setConnected(false);
          setError('WebSocket connection failed');
        });
      } catch (err) {
        setError('Failed to connect to log stream');
      }
    }

    connect();

    return () => {
      shouldReconnect = false;
      clearTimeout(reconnectTimer);
      if (wsRef.current) {
        wsRef.current.close();
        wsRef.current = null;
      }
    };
  }, [taskId, isActive]);

  // Keyboard shortcut: Escape to collapse
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        onCollapse();
      }
    };
    document.addEventListener('keydown', handler);
    return () => document.removeEventListener('keydown', handler);
  }, [onCollapse]);

  // Send a message/comment to the task
  const handleSendMessage = useCallback(async () => {
    if (!message.trim() || !taskId || sending) return;

    setSending(true);
    try {
      const response = await fetch(`/api/v1/mesh/tasks/${taskId}/message`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message: message.trim() }),
      });

      if (!response.ok) {
        const body = await response.json().catch(() => ({ message: response.statusText }));
        throw new Error(body.message || body.error || `HTTP ${response.status}`);
      }

      // Add as a user message in the log
      setLogs(prev => [...prev, {
        timestamp: new Date().toISOString(),
        content: message.trim(),
        type: 'user',
      }]);
      setMessage('');
      inputRef.current?.focus();
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to send message');
    } finally {
      setSending(false);
    }
  }, [message, taskId, sending]);

  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSendMessage();
    }
    // Prevent Escape from bubbling when input is focused
    if (e.key === 'Escape') {
      e.stopPropagation();
      inputRef.current?.blur();
    }
  }, [handleSendMessage]);

  // Interrupt the running task
  const handleInterrupt = useCallback(async () => {
    if (!taskId) return;
    try {
      // Send a special interrupt message
      const response = await fetch(`/api/v1/mesh/tasks/${taskId}/message`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message: '/interrupt' }),
      });
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }
      setLogs(prev => [...prev, {
        timestamp: new Date().toISOString(),
        content: 'Interrupt signal sent',
        type: 'system',
      }]);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to interrupt');
    }
  }, [taskId]);

  const formatTimestamp = (ts: string) => {
    try {
      return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
    } catch {
      return '';
    }
  };

  return (
    <div className="step-log-feed">
      {/* Header */}
      <div className="step-log-feed-header">
        <div className="step-log-feed-header-left">
          <span className="step-log-feed-title">{stepName} - Logs</span>
          <span className={`step-log-feed-status ${connected ? 'connected' : 'disconnected'}`}>
            {connected ? 'Live' : 'Disconnected'}
          </span>
        </div>
        <div className="step-log-feed-header-right">
          {isActive && (
            <button
              className="step-log-feed-interrupt-btn"
              onClick={handleInterrupt}
              title="Interrupt this contract"
            >
              &#x23F9; Interrupt
            </button>
          )}
          <button
            className="step-log-feed-collapse-btn"
            onClick={onCollapse}
            title="Collapse (Esc)"
          >
            &#x2715;
          </button>
        </div>
      </div>

      {/* Log content */}
      <div className="step-log-feed-content" ref={logContainerRef}>
        {logs.length === 0 && !error && (
          <div className="step-log-feed-empty">
            {isActive
              ? 'Waiting for log output...'
              : 'No logs available for this step.'}
          </div>
        )}

        {error && (
          <div className="step-log-feed-error">{error}</div>
        )}

        {logs.map((entry, idx) => (
          <div key={idx} className={`step-log-entry step-log-entry--${entry.type}`}>
            <span className="step-log-entry-time">{formatTimestamp(entry.timestamp)}</span>
            <span className="step-log-entry-content">{entry.content}</span>
          </div>
        ))}
        <div ref={logsEndRef} />
      </div>

      {/* Message input (comment/interrupt controls) */}
      {isActive && (
        <div className="step-log-feed-input">
          <input
            ref={inputRef}
            type="text"
            className="step-log-feed-input-field"
            placeholder="Send a message to this contract..."
            value={message}
            onChange={(e) => setMessage(e.target.value)}
            onKeyDown={handleKeyDown}
            disabled={sending}
          />
          <button
            className="step-log-feed-send-btn"
            onClick={handleSendMessage}
            disabled={!message.trim() || sending}
            title="Send message (Enter)"
          >
            {sending ? '...' : '&#x27A4;'}
          </button>
        </div>
      )}
    </div>
  );
}