diff options
Diffstat (limited to 'frontend/src/components/document/nodes')
| -rw-r--r-- | frontend/src/components/document/nodes/StepLogFeed.tsx | 277 | ||||
| -rw-r--r-- | frontend/src/components/document/nodes/StepsDiagramComponent.tsx | 239 |
2 files changed, 0 insertions, 516 deletions
diff --git a/frontend/src/components/document/nodes/StepLogFeed.tsx b/frontend/src/components/document/nodes/StepLogFeed.tsx deleted file mode 100644 index 2f2f553..0000000 --- a/frontend/src/components/document/nodes/StepLogFeed.tsx +++ /dev/null @@ -1,277 +0,0 @@ -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 contract" - > - ⏹ 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 contract..." - 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> - ); -} diff --git a/frontend/src/components/document/nodes/StepsDiagramComponent.tsx b/frontend/src/components/document/nodes/StepsDiagramComponent.tsx deleted file mode 100644 index ac1cb83..0000000 --- a/frontend/src/components/document/nodes/StepsDiagramComponent.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import React, { useEffect, useState, useRef, useCallback } from 'react'; -import { getDirective, DirectiveStep, DirectiveWithSteps } from '../../../services/directiveApi'; -import { StepLogFeed } from './StepLogFeed'; -import './StepsDiagram.css'; - -interface StepsDiagramComponentProps { - directiveId: string; - onExpandContract?: (step: DirectiveStep) => void; -} - -type StepStatus = 'pending' | 'ready' | 'running' | 'completed' | 'failed' | 'skipped'; - -const STATUS_LABELS: Record<string, string> = { - pending: 'Queued', - ready: 'Ready', - running: 'Executing', - completed: 'Fulfilled', - failed: 'Failed', - skipped: 'Skipped', -}; - -function formatTime(dateStr: string): string { - if (!dateStr) return ''; - const d = new Date(dateStr); - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); -} - -interface StepCardProps { - step: DirectiveStep; - isExpanded: boolean; - onToggleExpand: () => void; - onCollapse: () => void; -} - -function StepCard({ step, isExpanded, onToggleExpand, onCollapse }: StepCardProps) { - const status = (step.status || 'pending').toLowerCase() as StepStatus; - const hasTask = !!step.taskId || !!step.contractId; - const canExpand = hasTask && ['running', 'completed', 'failed'].includes(status); - - return ( - <div className={`steps-diagram-card steps-diagram-card--${status} ${isExpanded ? 'steps-diagram-card--expanded' : ''}`}> - <div - className={`steps-diagram-card-header ${canExpand ? 'steps-diagram-card-header--clickable' : ''}`} - onClick={canExpand ? onToggleExpand : undefined} - role={canExpand ? 'button' : undefined} - tabIndex={canExpand ? 0 : undefined} - onKeyDown={canExpand ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggleExpand(); } } : undefined} - > - <span className="steps-diagram-card-name">{step.name}</span> - <div className="steps-diagram-card-header-right"> - <span className={`steps-diagram-status-badge steps-diagram-status-badge--${status}`}> - {STATUS_LABELS[status] || status} - </span> - {canExpand && ( - <span className={`steps-diagram-expand-icon ${isExpanded ? 'expanded' : ''}`}> - ▶ - </span> - )} - </div> - </div> - {step.description && !isExpanded && ( - <p className="steps-diagram-card-desc">{step.description}</p> - )} - <div className="steps-diagram-card-footer"> - <span className="steps-diagram-card-index">#{step.orderIndex}</span> - {status === 'running' && ( - <span className="steps-diagram-card-progress">In progress...</span> - )} - {status === 'completed' && step.completedAt && ( - <span className="steps-diagram-card-time"> - Completed {formatTime(step.completedAt)} - </span> - )} - </div> - - {/* Expandable log feed */} - {isExpanded && hasTask && ( - <StepLogFeed - taskId={step.taskId || step.contractId} - stepName={step.name} - stepStatus={status} - onCollapse={onCollapse} - /> - )} - </div> - ); -} - -export function StepsDiagramComponent({ directiveId, onExpandContract }: StepsDiagramComponentProps) { - const [steps, setSteps] = useState<DirectiveStep[]>([]); - const [directiveStatus, setDirectiveStatus] = useState<string>(''); - const [loading, setLoading] = useState(true); - const [error, setError] = useState<string | null>(null); - const [expandedStepId, setExpandedStepId] = useState<string | null>(null); - const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); - const prevStepCountRef = useRef(0); - - const fetchSteps = useCallback(async () => { - try { - const data: DirectiveWithSteps = await getDirective(directiveId); - setSteps(data.steps || []); - setDirectiveStatus(data.status || ''); - setError(null); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load contracts'); - } finally { - setLoading(false); - } - }, [directiveId]); - - useEffect(() => { - fetchSteps(); - intervalRef.current = setInterval(fetchSteps, 5000); - return () => { - if (intervalRef.current) clearInterval(intervalRef.current); - }; - }, [fetchSteps]); - - // Track when new steps appear for animation - useEffect(() => { - prevStepCountRef.current = steps.length; - }, [steps.length]); - - // Keyboard shortcut: Escape to collapse expanded step - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape' && expandedStepId) { - setExpandedStepId(null); - } - }; - document.addEventListener('keydown', handler); - return () => document.removeEventListener('keydown', handler); - }, [expandedStepId]); - - const toggleExpand = useCallback((stepId: string) => { - setExpandedStepId(prev => prev === stepId ? null : stepId); - }, []); - - const collapseExpanded = useCallback(() => { - setExpandedStepId(null); - }, []); - - const completedCount = steps.filter(s => s.status?.toLowerCase() === 'completed').length; - const totalCount = steps.length; - const isActive = ['active', 'running', 'planning'].includes(directiveStatus.toLowerCase()); - const isBuilding = isActive && steps.length === 0; - - // Group steps by orderIndex - const groupedSteps: Map<number, DirectiveStep[]> = new Map(); - const sortedSteps = [...steps].sort((a, b) => a.orderIndex - b.orderIndex); - for (const step of sortedSteps) { - const idx = step.orderIndex; - if (!groupedSteps.has(idx)) groupedSteps.set(idx, []); - groupedSteps.get(idx)!.push(step); - } - const orderGroups = Array.from(groupedSteps.entries()).sort((a, b) => a[0] - b[0]); - - if (loading) { - return ( - <div className="steps-diagram" contentEditable={false}> - <div className="steps-diagram-header"> - <span className="steps-diagram-header-title">Contract Steps</span> - <span className="steps-diagram-header-author">Authored by Makima</span> - </div> - <div className="steps-diagram-loading"> - <div className="steps-diagram-spinner" /> - <span>Loading contracts...</span> - </div> - </div> - ); - } - - if (error) { - return ( - <div className="steps-diagram" contentEditable={false}> - <div className="steps-diagram-header"> - <span className="steps-diagram-header-title">Contract Steps</span> - <span className="steps-diagram-header-author">Authored by Makima</span> - </div> - <div className="steps-diagram-error">Failed to load contracts: {error}</div> - </div> - ); - } - - return ( - <div className={`steps-diagram ${expandedStepId ? 'steps-diagram--has-expanded' : ''}`} contentEditable={false}> - <div className="steps-diagram-header"> - <div className="steps-diagram-header-left"> - <span className="steps-diagram-header-title">Contract Steps</span> - {totalCount > 0 && ( - <span className="steps-diagram-header-count"> - {completedCount}/{totalCount} fulfilled - </span> - )} - </div> - <span className="steps-diagram-header-author">Authored by Makima</span> - </div> - - {isBuilding && ( - <div className="steps-diagram-planning"> - <div className="steps-diagram-planning-dots"> - <span /><span /><span /> - </div> - <span>Makima is drafting contracts...</span> - </div> - )} - - {totalCount === 0 && !isBuilding && ( - <div className="steps-diagram-empty">No contract steps defined yet.</div> - )} - - {totalCount > 0 && ( - <div className="steps-diagram-dag"> - {orderGroups.map(([orderIndex, groupSteps], groupIdx) => ( - <React.Fragment key={orderIndex}> - {groupIdx > 0 && ( - <div className="steps-diagram-arrow"> - <div className="steps-diagram-arrow-line" /> - <div className="steps-diagram-arrow-head" /> - </div> - )} - <div className="steps-diagram-group"> - {groupSteps.map((step) => ( - <StepCard - key={step.id} - step={step} - isExpanded={expandedStepId === step.id} - onToggleExpand={() => toggleExpand(step.id)} - onCollapse={collapseExpanded} - /> - ))} - </div> - </React.Fragment> - ))} - </div> - )} - </div> - ); -} |
