summaryrefslogblamecommitdiff
path: root/frontend/src/components/document/nodes/ContractLogFeed.tsx
blob: 79af91c2c68653aaaee725fbcacf0302e056a624 (plain) (tree)
































































































































































































































                                                                                                                 
import React, { useState, useRef, useEffect, useCallback } from 'react';
import {
  sendContractMessage,
  interruptContract,
  getContractOutput,
} from '../../../services/directiveApi';
import './ContractLogFeed.css';

interface ContractLogFeedProps {
  taskId: string;
  contractName: string;
  status: string;
  onClose?: () => void;
}

interface LogEntry {
  id: string;
  text: string;
  type: 'output' | 'user' | 'system';
  timestamp: Date;
}

const INTERACTIVE_STATUSES = ['running', 'starting'];

export function ContractLogFeed({ taskId, contractName, status, onClose }: ContractLogFeedProps) {
  const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
  const [message, setMessage] = useState('');
  const [sending, setSending] = useState(false);
  const [sentIndicator, setSentIndicator] = useState(false);
  const [interruptConfirm, setInterruptConfirm] = useState(false);
  const [interrupting, setInterrupting] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const logEndRef = useRef<HTMLDivElement>(null);
  const textareaRef = useRef<HTMLTextAreaElement>(null);
  const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
  const lastOutputRef = useRef<string>('');
  const entryIdRef = useRef(0);

  const isInteractive = INTERACTIVE_STATUSES.includes(status.toLowerCase());

  const addLogEntry = useCallback((text: string, type: LogEntry['type']) => {
    entryIdRef.current += 1;
    setLogEntries(prev => [
      ...prev,
      { id: `entry-${entryIdRef.current}`, text, type, timestamp: new Date() },
    ]);
  }, []);

  // Poll for contract output
  const fetchOutput = useCallback(async () => {
    if (!taskId) return;
    try {
      const data = await getContractOutput(taskId);
      const output = data.output || '';
      if (output && output !== lastOutputRef.current) {
        // Find new content
        const newContent = output.startsWith(lastOutputRef.current)
          ? output.slice(lastOutputRef.current.length).trim()
          : output.trim();
        lastOutputRef.current = output;
        if (newContent) {
          addLogEntry(newContent, 'output');
        }
      }
    } catch {
      // Silently ignore fetch errors for output polling
    }
  }, [taskId, addLogEntry]);

  useEffect(() => {
    fetchOutput();
    pollRef.current = setInterval(fetchOutput, 3000);
    return () => {
      if (pollRef.current) clearInterval(pollRef.current);
    };
  }, [fetchOutput]);

  // Auto-scroll to bottom on new entries
  useEffect(() => {
    logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [logEntries]);

  // Reset interrupt confirm after timeout
  useEffect(() => {
    if (!interruptConfirm) return;
    const timer = setTimeout(() => setInterruptConfirm(false), 3000);
    return () => clearTimeout(timer);
  }, [interruptConfirm]);

  const handleSendMessage = async () => {
    const trimmed = message.trim();
    if (!trimmed || sending || !isInteractive) return;

    setSending(true);
    setError(null);
    try {
      await sendContractMessage(taskId, trimmed);
      addLogEntry(trimmed, 'user');
      setMessage('');
      setSentIndicator(true);
      setTimeout(() => setSentIndicator(false), 1500);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to send message');
    } finally {
      setSending(false);
    }
  };

  const handleInterrupt = async () => {
    if (!interruptConfirm) {
      setInterruptConfirm(true);
      return;
    }

    setInterrupting(true);
    setError(null);
    setInterruptConfirm(false);
    try {
      await interruptContract(taskId);
      addLogEntry('Contract interrupted by user', 'system');
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to interrupt contract');
    } finally {
      setInterrupting(false);
    }
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSendMessage();
    }
  };

  const statusLower = status.toLowerCase();

  return (
    <div className="contract-log-feed">
      <div className="contract-log-feed-header">
        <div className="contract-log-feed-title">
          <span className="contract-log-feed-name">{contractName}</span>
          <span className={`contract-log-feed-status contract-log-feed-status--${statusLower}`}>
            {status}
          </span>
        </div>
        {onClose && (
          <button className="contract-log-feed-close" onClick={onClose} title="Close">
            &times;
          </button>
        )}
      </div>

      <div className="contract-log-feed-content">
        {logEntries.length === 0 && (
          <div className="contract-log-feed-empty">
            {isInteractive ? 'Waiting for output...' : 'No output available.'}
          </div>
        )}
        {logEntries.map(entry => (
          <div key={entry.id} className={`contract-log-entry contract-log-entry--${entry.type}`}>
            <span className="contract-log-entry-time">
              {entry.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
            </span>
            <span className="contract-log-entry-text">{entry.text}</span>
          </div>
        ))}
        <div ref={logEndRef} />
      </div>

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

      {isInteractive && (
        <div className="contract-interaction-bar">
          <div className="contract-interaction-message-row">
            <textarea
              ref={textareaRef}
              className="contract-message-input"
              value={message}
              onChange={e => setMessage(e.target.value)}
              onKeyDown={handleKeyDown}
              placeholder="Send a message to the contract... (Enter to send, Shift+Enter for newline)"
              rows={1}
              disabled={sending}
            />
            <button
              className="contract-send-btn"
              onClick={handleSendMessage}
              disabled={sending || !message.trim()}
              title="Send message"
            >
              {sending ? 'Sending...' : 'Send'}
            </button>
            {sentIndicator && (
              <span className="contract-sent-indicator">Sent</span>
            )}
          </div>
          <div className="contract-interaction-actions-row">
            <button
              className={`contract-interrupt-btn ${interruptConfirm ? 'contract-interrupt-btn--confirm' : ''}`}
              onClick={handleInterrupt}
              disabled={interrupting}
              title={interruptConfirm ? 'Click again to confirm interrupt' : 'Interrupt contract'}
            >
              {interrupting
                ? 'Interrupting...'
                : interruptConfirm
                  ? 'Click again to confirm interrupt'
                  : 'Interrupt'}
            </button>
          </div>
        </div>
      )}

      {!isInteractive && statusLower !== 'pending' && statusLower !== 'ready' && (
        <div className="contract-interaction-bar contract-interaction-bar--disabled">
          <span className="contract-interaction-disabled-text">
            Contract is {status.toLowerCase()} - interaction unavailable
          </span>
        </div>
      )}
    </div>
  );
}