From d513f93c84ae985738e0f696fcb72fa1153046ef Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
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
---
 .../src/components/document/nodes/StepLogFeed.tsx  | 277 +++++++++++++++++++++
 1 file changed, 277 insertions(+)
 create mode 100644 frontend/src/components/document/nodes/StepLogFeed.tsx

(limited to 'frontend/src/components/document/nodes/StepLogFeed.tsx')

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"
+            >
+              &#x23F9; Interrupt
+            </button>
+          )}
+          <button
+            className="step-log-feed-collapse-btn"
+            onClick={onCollapse}
+            title="Collapse (Esc)"
+          >
+            &#x2715;
+          </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 ? '...' : '&#x27A4;'}
+          </button>
+        </div>
+      )}
+    </div>
+  );
+}
-- 
cgit v1.2.3

