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">
×
</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>
);
}