From d513f93c84ae985738e0f696fcb72fa1153046ef Mon Sep 17 00:00:00 2001 From: soryu Date: Tue, 28 Apr 2026 17:35:08 +0100 Subject: feat: document UI with contract blocks, expandable logs, and interaction controls (#97) * feat: soryu-co/soryu - makima: Rename tasks to contracts in directive API and types * feat: soryu-co/soryu - makima: Add contract interaction panel with comment and interrupt * feat: soryu-co/soryu - makima: Build expandable contract log feed in StepsDiagram * feat: soryu-co/soryu - makima: Rename tasks to contracts throughout document UI and add contract block support * feat: soryu-co/soryu - makima: Add comment and interrupt controls to expanded step log feed * feat: soryu-co/soryu - makima: Audit and fix Document UI feature flag visibility and missing implementations * feat: soryu-co/soryu - makima: Add expandable step rows with live log feed in StepsDiagram * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Integrate all document UI components and final polish --- .../components/document/nodes/ContractLogFeed.tsx | 225 +++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 frontend/src/components/document/nodes/ContractLogFeed.tsx (limited to 'frontend/src/components/document/nodes/ContractLogFeed.tsx') 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([]); + 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 && ( +
+
+