diff options
Diffstat (limited to 'frontend/src/components/document/nodes/ContractLogFeed.tsx')
| -rw-r--r-- | frontend/src/components/document/nodes/ContractLogFeed.tsx | 225 |
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"> + × + </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> + ); +} |
