summaryrefslogtreecommitdiff
path: root/frontend/src/components/document/nodes/ContractLogFeed.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components/document/nodes/ContractLogFeed.tsx')
-rw-r--r--frontend/src/components/document/nodes/ContractLogFeed.tsx225
1 files changed, 225 insertions, 0 deletions
diff --git a/frontend/src/components/document/nodes/ContractLogFeed.tsx b/frontend/src/components/document/nodes/ContractLogFeed.tsx
new file mode 100644
index 0000000..79af91c
--- /dev/null
+++ b/frontend/src/components/document/nodes/ContractLogFeed.tsx
@@ -0,0 +1,225 @@
+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>
+ );
+}