diff options
| author | soryu <soryu@soryu.co> | 2026-04-28 17:35:08 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-28 17:35:08 +0100 |
| commit | d513f93c84ae985738e0f696fcb72fa1153046ef (patch) | |
| tree | d169fa48ce93f1e204a80b60ca9295772bc2fa63 /frontend/src/components/document/nodes/StepLogFeed.tsx | |
| parent | 5aa3fafb4acfa89c7d04e84abf7861607733e8ce (diff) | |
| download | soryu-d513f93c84ae985738e0f696fcb72fa1153046ef.tar.gz soryu-d513f93c84ae985738e0f696fcb72fa1153046ef.zip | |
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
Diffstat (limited to 'frontend/src/components/document/nodes/StepLogFeed.tsx')
| -rw-r--r-- | frontend/src/components/document/nodes/StepLogFeed.tsx | 277 |
1 files changed, 277 insertions, 0 deletions
diff --git a/frontend/src/components/document/nodes/StepLogFeed.tsx b/frontend/src/components/document/nodes/StepLogFeed.tsx new file mode 100644 index 0000000..0357de8 --- /dev/null +++ b/frontend/src/components/document/nodes/StepLogFeed.tsx @@ -0,0 +1,277 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; + +interface StepLogFeedProps { + taskId: string; + stepName: string; + stepStatus: string; + onCollapse: () => void; +} + +interface LogEntry { + timestamp: string; + content: string; + type: 'stdout' | 'stderr' | 'system' | 'user'; +} + +/** + * Live log feed for an expanded step row. + * Connects via WebSocket to stream task output and allows + * sending messages (comments) and interrupting the task. + */ +export function StepLogFeed({ taskId, stepName, stepStatus, onCollapse }: StepLogFeedProps) { + const [logs, setLogs] = useState<LogEntry[]>([]); + const [message, setMessage] = useState(''); + const [sending, setSending] = useState(false); + const [connected, setConnected] = useState(false); + const [error, setError] = useState<string | null>(null); + const logsEndRef = useRef<HTMLDivElement>(null); + const wsRef = useRef<WebSocket | null>(null); + const logContainerRef = useRef<HTMLDivElement>(null); + const inputRef = useRef<HTMLInputElement>(null); + + const isActive = ['running', 'starting'].includes(stepStatus.toLowerCase()); + + // Auto-scroll to bottom when new logs arrive + useEffect(() => { + logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [logs]); + + // Connect to WebSocket for live streaming + useEffect(() => { + if (!taskId) return; + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/api/v1/mesh/tasks/subscribe`; + + let ws: WebSocket; + let reconnectTimer: ReturnType<typeof setTimeout>; + let shouldReconnect = true; + + function connect() { + try { + ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.addEventListener('open', () => { + setConnected(true); + setError(null); + // Subscribe to this specific task + ws.send(JSON.stringify({ type: 'subscribe', taskId })); + }); + + ws.addEventListener('message', (evt) => { + try { + const data = JSON.parse(evt.data); + // Handle different message formats from the backend + if (data.taskId === taskId || data.task_id === taskId) { + const entry: LogEntry = { + timestamp: data.timestamp || new Date().toISOString(), + content: data.content || data.output || data.message || JSON.stringify(data), + type: data.type || data.stream || 'stdout', + }; + setLogs(prev => [...prev, entry]); + } + } catch { + // Non-JSON message, treat as raw log + setLogs(prev => [...prev, { + timestamp: new Date().toISOString(), + content: evt.data, + type: 'stdout', + }]); + } + }); + + ws.addEventListener('close', () => { + setConnected(false); + wsRef.current = null; + if (shouldReconnect && isActive) { + reconnectTimer = setTimeout(connect, 3000); + } + }); + + ws.addEventListener('error', () => { + setConnected(false); + setError('WebSocket connection failed'); + }); + } catch (err) { + setError('Failed to connect to log stream'); + } + } + + connect(); + + return () => { + shouldReconnect = false; + clearTimeout(reconnectTimer); + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }; + }, [taskId, isActive]); + + // Keyboard shortcut: Escape to collapse + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onCollapse(); + } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [onCollapse]); + + // Send a message/comment to the task + const handleSendMessage = useCallback(async () => { + if (!message.trim() || !taskId || sending) return; + + setSending(true); + try { + const response = await fetch(`/api/v1/mesh/tasks/${taskId}/message`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: message.trim() }), + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(body.message || body.error || `HTTP ${response.status}`); + } + + // Add as a user message in the log + setLogs(prev => [...prev, { + timestamp: new Date().toISOString(), + content: message.trim(), + type: 'user', + }]); + setMessage(''); + inputRef.current?.focus(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to send message'); + } finally { + setSending(false); + } + }, [message, taskId, sending]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + // Prevent Escape from bubbling when input is focused + if (e.key === 'Escape') { + e.stopPropagation(); + inputRef.current?.blur(); + } + }, [handleSendMessage]); + + // Interrupt the running task + const handleInterrupt = useCallback(async () => { + if (!taskId) return; + try { + // Send a special interrupt message + const response = await fetch(`/api/v1/mesh/tasks/${taskId}/message`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: '/interrupt' }), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + setLogs(prev => [...prev, { + timestamp: new Date().toISOString(), + content: 'Interrupt signal sent', + type: 'system', + }]); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to interrupt'); + } + }, [taskId]); + + const formatTimestamp = (ts: string) => { + try { + return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } catch { + return ''; + } + }; + + return ( + <div className="step-log-feed"> + {/* Header */} + <div className="step-log-feed-header"> + <div className="step-log-feed-header-left"> + <span className="step-log-feed-title">{stepName} - Logs</span> + <span className={`step-log-feed-status ${connected ? 'connected' : 'disconnected'}`}> + {connected ? 'Live' : 'Disconnected'} + </span> + </div> + <div className="step-log-feed-header-right"> + {isActive && ( + <button + className="step-log-feed-interrupt-btn" + onClick={handleInterrupt} + title="Interrupt this task" + > + ⏹ Interrupt + </button> + )} + <button + className="step-log-feed-collapse-btn" + onClick={onCollapse} + title="Collapse (Esc)" + > + ✕ + </button> + </div> + </div> + + {/* Log content */} + <div className="step-log-feed-content" ref={logContainerRef}> + {logs.length === 0 && !error && ( + <div className="step-log-feed-empty"> + {isActive + ? 'Waiting for log output...' + : 'No logs available for this step.'} + </div> + )} + + {error && ( + <div className="step-log-feed-error">{error}</div> + )} + + {logs.map((entry, idx) => ( + <div key={idx} className={`step-log-entry step-log-entry--${entry.type}`}> + <span className="step-log-entry-time">{formatTimestamp(entry.timestamp)}</span> + <span className="step-log-entry-content">{entry.content}</span> + </div> + ))} + <div ref={logsEndRef} /> + </div> + + {/* Message input (comment/interrupt controls) */} + {isActive && ( + <div className="step-log-feed-input"> + <input + ref={inputRef} + type="text" + className="step-log-feed-input-field" + placeholder="Send a message to this task..." + value={message} + onChange={(e) => setMessage(e.target.value)} + onKeyDown={handleKeyDown} + disabled={sending} + /> + <button + className="step-log-feed-send-btn" + onClick={handleSendMessage} + disabled={!message.trim() || sending} + title="Send message (Enter)" + > + {sending ? '...' : '➤'} + </button> + </div> + )} + </div> + ); +} |
