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([]); 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(null); const logEndRef = useRef(null); const textareaRef = useRef(null); const pollRef = useRef | null>(null); const lastOutputRef = useRef(''); 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) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSendMessage(); } }; const statusLower = status.toLowerCase(); return (
{contractName} {status}
{onClose && ( )}
{logEntries.length === 0 && (
{isInteractive ? 'Waiting for output...' : 'No output available.'}
)} {logEntries.map(entry => (
{entry.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })} {entry.text}
))}
{error && (
{error}
)} {isInteractive && (