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/ContractBlock.css | 123 ++++++++ .../document/nodes/ContractBlockComponent.tsx | 117 +++++++ .../document/nodes/ContractBlockNode.tsx | 106 +++++++ .../components/document/nodes/ContractLogFeed.css | 346 +++++++++++++++++++++ .../components/document/nodes/ContractLogFeed.tsx | 225 ++++++++++++++ .../src/components/document/nodes/StepLogFeed.tsx | 277 +++++++++++++++++ .../src/components/document/nodes/StepsDiagram.css | 331 +++++++++++++++++++- .../document/nodes/StepsDiagramComponent.tsx | 142 +++++++-- 8 files changed, 1640 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/document/nodes/ContractBlock.css create mode 100644 frontend/src/components/document/nodes/ContractBlockComponent.tsx create mode 100644 frontend/src/components/document/nodes/ContractBlockNode.tsx create mode 100644 frontend/src/components/document/nodes/ContractLogFeed.css create mode 100644 frontend/src/components/document/nodes/ContractLogFeed.tsx create mode 100644 frontend/src/components/document/nodes/StepLogFeed.tsx (limited to 'frontend/src/components/document/nodes') diff --git a/frontend/src/components/document/nodes/ContractBlock.css b/frontend/src/components/document/nodes/ContractBlock.css new file mode 100644 index 0000000..80edb74 --- /dev/null +++ b/frontend/src/components/document/nodes/ContractBlock.css @@ -0,0 +1,123 @@ +/* ============================================ + Contract Block - Inline contract reference + ============================================ */ + +.contract-block-wrapper { + margin: 1rem 0; + user-select: none; +} + +.contract-block { + background: #fafbff; + border: 1px solid #e2e5ef; + border-radius: 8px; + padding: 0.65rem 0.85rem; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-size: 13px; + color: #374151; + transition: box-shadow 0.2s ease, border-color 0.2s ease; + animation: contractBlockAppear 0.25s ease-out both; +} + +@keyframes contractBlockAppear { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.contract-block:hover { + border-color: #c7cce0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +} + +.contract-block--error { + border-color: #fecaca; + background: #fef2f2; +} + +/* Header */ +.contract-block-header { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.contract-block-icon { + font-size: 1rem; + flex-shrink: 0; +} + +.contract-block-name { + font-weight: 600; + font-size: 0.88rem; + color: #1f2937; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.contract-block-phase-badge { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.1rem 0.4rem; + border-radius: 8px; + white-space: nowrap; + flex-shrink: 0; +} + +.contract-block-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +/* Meta */ +.contract-block-meta { + margin-top: 0.3rem; + padding-left: 1.5rem; +} + +.contract-block-type { + font-size: 0.75rem; + color: #9ca3af; + font-style: italic; +} + +.contract-block-error-msg { + margin-top: 0.25rem; + font-size: 0.78rem; + color: #dc2626; + padding-left: 1.5rem; +} + +/* Loading state */ +.contract-block-loading { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0; + color: #9ca3af; + font-size: 0.82rem; +} + +.contract-block-spinner { + width: 14px; + height: 14px; + border: 2px solid #e5e7eb; + border-top-color: #6b7280; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/frontend/src/components/document/nodes/ContractBlockComponent.tsx b/frontend/src/components/document/nodes/ContractBlockComponent.tsx new file mode 100644 index 0000000..0d9a25a --- /dev/null +++ b/frontend/src/components/document/nodes/ContractBlockComponent.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useState } from 'react'; +import './ContractBlock.css'; + +interface ContractBlockComponentProps { + contractId: string; + contractName: string; +} + +interface ContractInfo { + id: string; + name: string; + status: string; + phase: string; + contract_type: string; +} + +const PHASE_COLORS: Record = { + planning: '#3b82f6', + execution: '#f59e0b', + review: '#8b5cf6', + completed: '#10b981', + failed: '#ef4444', +}; + +const STATUS_COLORS: Record = { + active: '#10b981', + running: '#10b981', + idle: '#f59e0b', + paused: '#f59e0b', + completed: '#10b981', + failed: '#ef4444', + archived: '#6b7280', +}; + +export function ContractBlockComponent({ contractId, contractName }: ContractBlockComponentProps) { + const [contract, setContract] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function fetchContract() { + try { + const response = await fetch(`/api/v1/contracts/${contractId}`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + if (!cancelled) { + setContract(data.contract || data); + setError(null); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Failed to load'); + } + } finally { + if (!cancelled) setLoading(false); + } + } + + fetchContract(); + return () => { cancelled = true; }; + }, [contractId]); + + if (loading) { + return ( +
+
+
+ Loading contract... +
+
+ ); + } + + if (error) { + return ( +
+
+ 📦 + {contractName} +
+
Unable to load: {error}
+
+ ); + } + + const phase = contract?.phase?.toLowerCase() || 'unknown'; + const status = contract?.status?.toLowerCase() || 'unknown'; + const phaseColor = PHASE_COLORS[phase] || '#6b7280'; + const statusColor = STATUS_COLORS[status] || '#6b7280'; + + return ( +
+
+ 📦 + {contract?.name || contractName} + + {phase} + + +
+ {contract?.contract_type && ( +
+ {contract.contract_type} +
+ )} +
+ ); +} diff --git a/frontend/src/components/document/nodes/ContractBlockNode.tsx b/frontend/src/components/document/nodes/ContractBlockNode.tsx new file mode 100644 index 0000000..86e4c9d --- /dev/null +++ b/frontend/src/components/document/nodes/ContractBlockNode.tsx @@ -0,0 +1,106 @@ +import { + DecoratorNode, + DOMExportOutput, + LexicalNode, + NodeKey, + SerializedLexicalNode, + Spread, +} from 'lexical'; +import React from 'react'; +import { ContractBlockComponent } from './ContractBlockComponent'; + +export type SerializedContractBlockNode = Spread< + { + contractId: string; + contractName: string; + }, + SerializedLexicalNode +>; + +export class ContractBlockNode extends DecoratorNode { + __contractId: string; + __contractName: string; + + static getType(): string { + return 'contract-block'; + } + + static clone(node: ContractBlockNode): ContractBlockNode { + return new ContractBlockNode(node.__contractId, node.__contractName, node.__key); + } + + constructor(contractId: string, contractName: string, key?: NodeKey) { + super(key); + this.__contractId = contractId; + this.__contractName = contractName; + } + + createDOM(): HTMLElement { + const div = document.createElement('div'); + div.className = 'contract-block-wrapper'; + return div; + } + + updateDOM(): boolean { + return false; + } + + decorate(): JSX.Element { + return ( + + ); + } + + exportJSON(): SerializedContractBlockNode { + return { + ...super.exportJSON(), + type: 'contract-block', + contractId: this.__contractId, + contractName: this.__contractName, + version: 1, + }; + } + + static importJSON(serializedNode: SerializedContractBlockNode): ContractBlockNode { + return $createContractBlockNode( + serializedNode.contractId, + serializedNode.contractName + ); + } + + isInline(): boolean { + return false; + } + + canInsertTextBefore(): boolean { + return false; + } + + canInsertTextAfter(): boolean { + return false; + } + + exportDOM(): DOMExportOutput { + const element = document.createElement('div'); + element.className = 'contract-block-wrapper'; + element.setAttribute('data-contract-id', this.__contractId); + element.textContent = `[Contract: ${this.__contractName}]`; + return { element }; + } +} + +export function $createContractBlockNode( + contractId: string, + contractName: string +): ContractBlockNode { + return new ContractBlockNode(contractId, contractName); +} + +export function $isContractBlockNode( + node: LexicalNode | null | undefined, +): node is ContractBlockNode { + return node instanceof ContractBlockNode; +} diff --git a/frontend/src/components/document/nodes/ContractLogFeed.css b/frontend/src/components/document/nodes/ContractLogFeed.css new file mode 100644 index 0000000..b5dd15d --- /dev/null +++ b/frontend/src/components/document/nodes/ContractLogFeed.css @@ -0,0 +1,346 @@ +/* ============================================ + Contract Log Feed + ============================================ */ + +.contract-log-feed { + display: flex; + flex-direction: column; + background: #1a1d23; + border: 1px solid #2d3039; + border-radius: 8px; + overflow: hidden; + margin-top: 0.5rem; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-size: 13px; + max-height: 420px; + animation: logFeedSlideIn 0.25s ease-out; +} + +@keyframes logFeedSlideIn { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ---- Header ---- */ +.contract-log-feed-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.75rem; + background: #22252b; + border-bottom: 1px solid #2d3039; +} + +.contract-log-feed-title { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.contract-log-feed-name { + font-weight: 600; + font-size: 0.82rem; + color: #e5e7eb; +} + +.contract-log-feed-status { + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.1rem 0.4rem; + border-radius: 8px; +} + +.contract-log-feed-status--running, +.contract-log-feed-status--starting { + background: rgba(245, 158, 11, 0.2); + color: #fbbf24; + animation: statusPulse 2s ease-in-out infinite; +} + +.contract-log-feed-status--completed { + background: rgba(16, 185, 129, 0.2); + color: #34d399; +} + +.contract-log-feed-status--failed { + background: rgba(239, 68, 68, 0.2); + color: #f87171; +} + +.contract-log-feed-status--pending, +.contract-log-feed-status--ready { + background: rgba(107, 114, 128, 0.2); + color: #9ca3af; +} + +.contract-log-feed-close { + background: none; + border: none; + color: #6b7280; + font-size: 1.1rem; + cursor: pointer; + padding: 0 0.25rem; + line-height: 1; + border-radius: 3px; + transition: color 0.15s, background 0.15s; +} + +.contract-log-feed-close:hover { + color: #e5e7eb; + background: rgba(255, 255, 255, 0.08); +} + +/* ---- Log Content ---- */ +.contract-log-feed-content { + flex: 1; + overflow-y: auto; + padding: 0.5rem 0.75rem; + min-height: 80px; + max-height: 240px; + scrollbar-width: thin; + scrollbar-color: #3a3f4b transparent; +} + +.contract-log-feed-content::-webkit-scrollbar { + width: 5px; +} + +.contract-log-feed-content::-webkit-scrollbar-thumb { + background: #3a3f4b; + border-radius: 3px; +} + +.contract-log-feed-empty { + color: #6b7280; + font-size: 0.82rem; + font-style: italic; + text-align: center; + padding: 1.5rem 0; +} + +/* ---- Log Entry ---- */ +.contract-log-entry { + display: flex; + gap: 0.5rem; + padding: 0.2rem 0; + line-height: 1.5; + animation: entryFadeIn 0.2s ease-out; +} + +@keyframes entryFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.contract-log-entry-time { + flex-shrink: 0; + font-size: 0.7rem; + color: #4b5563; + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + line-height: 1.65; +} + +.contract-log-entry-text { + color: #d1d5db; + white-space: pre-wrap; + word-break: break-word; + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + font-size: 0.78rem; +} + +.contract-log-entry--user .contract-log-entry-text { + color: #93c5fd; +} + +.contract-log-entry--user::before { + content: '>'; + color: #3b82f6; + font-weight: 700; + font-family: monospace; + flex-shrink: 0; + line-height: 1.5; +} + +.contract-log-entry--system .contract-log-entry-text { + color: #fbbf24; + font-style: italic; +} + +/* ---- Error ---- */ +.contract-log-feed-error { + padding: 0.4rem 0.75rem; + background: rgba(239, 68, 68, 0.12); + border-top: 1px solid rgba(239, 68, 68, 0.25); + color: #f87171; + font-size: 0.78rem; +} + +/* ---- Interaction Bar ---- */ +.contract-interaction-bar { + border-top: 1px solid #2d3039; + padding: 0.5rem 0.75rem; + background: #22252b; +} + +.contract-interaction-bar--disabled { + display: flex; + align-items: center; + justify-content: center; + padding: 0.6rem 0.75rem; +} + +.contract-interaction-disabled-text { + color: #6b7280; + font-size: 0.78rem; + font-style: italic; +} + +.contract-interaction-message-row { + display: flex; + align-items: flex-end; + gap: 0.4rem; + position: relative; +} + +.contract-message-input { + flex: 1; + background: #1a1d23; + border: 1px solid #3a3f4b; + border-radius: 6px; + color: #e5e7eb; + padding: 0.4rem 0.6rem; + font-size: 0.82rem; + font-family: inherit; + resize: none; + min-height: 32px; + max-height: 80px; + line-height: 1.4; + outline: none; + transition: border-color 0.15s; +} + +.contract-message-input::placeholder { + color: #4b5563; + font-size: 0.78rem; +} + +.contract-message-input:focus { + border-color: #3b82f6; +} + +.contract-message-input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.contract-send-btn { + flex-shrink: 0; + background: #3b82f6; + color: #fff; + border: none; + border-radius: 6px; + padding: 0.4rem 0.85rem; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; + min-height: 32px; +} + +.contract-send-btn:hover:not(:disabled) { + background: #2563eb; +} + +.contract-send-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.contract-sent-indicator { + position: absolute; + right: 0; + top: -1.4rem; + font-size: 0.7rem; + color: #34d399; + font-weight: 500; + animation: sentFlash 1.5s ease-out forwards; +} + +@keyframes sentFlash { + 0% { + opacity: 1; + transform: translateY(0); + } + 70% { + opacity: 1; + } + 100% { + opacity: 0; + transform: translateY(-4px); + } +} + +/* ---- Actions Row ---- */ +.contract-interaction-actions-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.4rem; +} + +.contract-interrupt-btn { + background: transparent; + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 6px; + padding: 0.3rem 0.7rem; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.contract-interrupt-btn:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.5); +} + +.contract-interrupt-btn--confirm { + background: rgba(239, 68, 68, 0.15); + border-color: #ef4444; + color: #f87171; + animation: confirmPulse 0.8s ease-in-out infinite; +} + +@keyframes confirmPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +.contract-interrupt-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ---- Responsive ---- */ +@media (max-width: 640px) { + .contract-log-feed { + max-height: 360px; + } + + .contract-log-feed-content { + max-height: 180px; + } + + .contract-message-input::placeholder { + font-size: 0.72rem; + } +} 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 && ( +
+
+