diff options
Diffstat (limited to 'frontend/src/components/document/nodes')
8 files changed, 1640 insertions, 27 deletions
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<string, string> = { + planning: '#3b82f6', + execution: '#f59e0b', + review: '#8b5cf6', + completed: '#10b981', + failed: '#ef4444', +}; + +const STATUS_COLORS: Record<string, string> = { + active: '#10b981', + running: '#10b981', + idle: '#f59e0b', + paused: '#f59e0b', + completed: '#10b981', + failed: '#ef4444', + archived: '#6b7280', +}; + +export function ContractBlockComponent({ contractId, contractName }: ContractBlockComponentProps) { + const [contract, setContract] = useState<ContractInfo | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(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 ( + <div className="contract-block" contentEditable={false}> + <div className="contract-block-loading"> + <div className="contract-block-spinner" /> + <span>Loading contract...</span> + </div> + </div> + ); + } + + if (error) { + return ( + <div className="contract-block contract-block--error" contentEditable={false}> + <div className="contract-block-header"> + <span className="contract-block-icon">📦</span> + <span className="contract-block-name">{contractName}</span> + </div> + <div className="contract-block-error-msg">Unable to load: {error}</div> + </div> + ); + } + + 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 ( + <div className="contract-block" contentEditable={false}> + <div className="contract-block-header"> + <span className="contract-block-icon">📦</span> + <span className="contract-block-name">{contract?.name || contractName}</span> + <span + className="contract-block-phase-badge" + style={{ backgroundColor: phaseColor + '20', color: phaseColor }} + > + {phase} + </span> + <span + className="contract-block-status-dot" + style={{ backgroundColor: statusColor }} + title={status} + /> + </div> + {contract?.contract_type && ( + <div className="contract-block-meta"> + <span className="contract-block-type">{contract.contract_type}</span> + </div> + )} + </div> + ); +} 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<JSX.Element> { + __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 ( + <ContractBlockComponent + contractId={this.__contractId} + contractName={this.__contractName} + /> + ); + } + + 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<LogEntry[]>([]); + 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<string | null>(null); + const logEndRef = useRef<HTMLDivElement>(null); + const textareaRef = useRef<HTMLTextAreaElement>(null); + const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); + const lastOutputRef = useRef<string>(''); + 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<HTMLTextAreaElement>) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }; + + const statusLower = status.toLowerCase(); + + return ( + <div className="contract-log-feed"> + <div className="contract-log-feed-header"> + <div className="contract-log-feed-title"> + <span className="contract-log-feed-name">{contractName}</span> + <span className={`contract-log-feed-status contract-log-feed-status--${statusLower}`}> + {status} + </span> + </div> + {onClose && ( + <button className="contract-log-feed-close" onClick={onClose} title="Close"> + × + </button> + )} + </div> + + <div className="contract-log-feed-content"> + {logEntries.length === 0 && ( + <div className="contract-log-feed-empty"> + {isInteractive ? 'Waiting for output...' : 'No output available.'} + </div> + )} + {logEntries.map(entry => ( + <div key={entry.id} className={`contract-log-entry contract-log-entry--${entry.type}`}> + <span className="contract-log-entry-time"> + {entry.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })} + </span> + <span className="contract-log-entry-text">{entry.text}</span> + </div> + ))} + <div ref={logEndRef} /> + </div> + + {error && ( + <div className="contract-log-feed-error">{error}</div> + )} + + {isInteractive && ( + <div className="contract-interaction-bar"> + <div className="contract-interaction-message-row"> + <textarea + ref={textareaRef} + className="contract-message-input" + value={message} + onChange={e => setMessage(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Send a message to the contract... (Enter to send, Shift+Enter for newline)" + rows={1} + disabled={sending} + /> + <button + className="contract-send-btn" + onClick={handleSendMessage} + disabled={sending || !message.trim()} + title="Send message" + > + {sending ? 'Sending...' : 'Send'} + </button> + {sentIndicator && ( + <span className="contract-sent-indicator">Sent</span> + )} + </div> + <div className="contract-interaction-actions-row"> + <button + className={`contract-interrupt-btn ${interruptConfirm ? 'contract-interrupt-btn--confirm' : ''}`} + onClick={handleInterrupt} + disabled={interrupting} + title={interruptConfirm ? 'Click again to confirm interrupt' : 'Interrupt contract'} + > + {interrupting + ? 'Interrupting...' + : interruptConfirm + ? 'Click again to confirm interrupt' + : 'Interrupt'} + </button> + </div> + </div> + )} + + {!isInteractive && statusLower !== 'pending' && statusLower !== 'ready' && ( + <div className="contract-interaction-bar contract-interaction-bar--disabled"> + <span className="contract-interaction-disabled-text"> + Contract is {status.toLowerCase()} - interaction unavailable + </span> + </div> + )} + </div> + ); +} 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> + ); +} diff --git a/frontend/src/components/document/nodes/StepsDiagram.css b/frontend/src/components/document/nodes/StepsDiagram.css index f3e9305..9856c6d 100644 --- a/frontend/src/components/document/nodes/StepsDiagram.css +++ b/frontend/src/components/document/nodes/StepsDiagram.css @@ -15,6 +15,12 @@ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-size: 14px; color: #374151; + transition: max-height 0.3s ease; +} + +.steps-diagram--has-expanded { + /* Allow more vertical space when a step is expanded */ + max-height: none; } /* ---- Header ---- */ @@ -92,18 +98,29 @@ border-top: 6px solid #cbd5e1; } -/* ---- Step Card ---- */ -.steps-diagram-card { +/* ---- Step Card Wrapper ---- */ +.steps-diagram-card-wrapper { flex: 1 1 180px; max-width: 280px; +} + +/* ---- Step Card ---- */ +.steps-diagram-card { background: #ffffff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 0.65rem 0.8rem; - transition: box-shadow 0.2s ease, border-color 0.2s ease; + transition: box-shadow 0.2s ease, border-color 0.2s ease, max-width 0.3s ease; animation: stepCardAppear 0.35s ease-out both; } +.steps-diagram-card--expanded { + flex: 1 1 100%; + max-width: 100%; + border-color: #93c5fd; + box-shadow: 0 2px 12px rgba(59, 130, 246, 0.1); +} + @keyframes stepCardAppear { from { opacity: 0; @@ -119,6 +136,19 @@ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); } +.steps-diagram-card--expandable { + cursor: pointer; +} + +.steps-diagram-card--expandable:hover { + border-color: #c7cbd5; +} + +.steps-diagram-card--expanded { + border-radius: 8px 8px 0 0; + border-bottom-color: transparent; +} + .steps-diagram-card-header { display: flex; align-items: center; @@ -127,6 +157,22 @@ margin-bottom: 0.3rem; } +.steps-diagram-card-header--clickable { + cursor: pointer; + user-select: none; +} + +.steps-diagram-card-header--clickable:hover .steps-diagram-card-name { + color: #2563eb; +} + +.steps-diagram-card-header-right { + display: flex; + align-items: center; + gap: 0.4rem; + flex-shrink: 0; +} + .steps-diagram-card-name { font-weight: 600; font-size: 0.85rem; @@ -135,6 +181,7 @@ text-overflow: ellipsis; white-space: nowrap; flex: 1; + transition: color 0.15s; } .steps-diagram-card-desc { @@ -160,6 +207,16 @@ font-weight: 500; } +.steps-diagram-card-contract-ref { + font-family: 'SF Mono', SFMono-Regular, ui-monospace, Menlo, monospace; + font-size: 0.68rem; + color: #6b7280; + background: #f3f4f6; + padding: 0.08rem 0.35rem; + border-radius: 4px; + cursor: default; +} + .steps-diagram-card-progress { color: #d97706; font-style: italic; @@ -169,6 +226,23 @@ color: #6b7280; } +/* ---- Expand Icon ---- */ +.steps-diagram-expand-icon { + font-size: 0.6rem; + color: #9ca3af; + transition: transform 0.2s ease, color 0.15s; + display: inline-block; +} + +.steps-diagram-expand-icon.expanded { + transform: rotate(90deg); + color: #3b82f6; +} + +.steps-diagram-card-header--clickable:hover .steps-diagram-expand-icon { + color: #3b82f6; +} + /* ---- Status Badge ---- */ .steps-diagram-status-badge { font-size: 0.68rem; @@ -245,6 +319,19 @@ opacity: 0.7; } +/* ---- Expanded Card ---- */ +.steps-diagram-card--expanded { + flex: 1 1 100%; + max-width: 100%; +} + +.steps-diagram-card-expand { + flex-shrink: 0; + font-size: 0.7rem; + color: #9ca3af; + margin-left: 0.25rem; +} + /* ---- Animations ---- */ @keyframes statusPulse { 0%, 100% { @@ -347,14 +434,250 @@ font-size: 0.82rem; } +/* ============================================ + Step Log Feed (Expandable) + ============================================ */ + +.step-log-feed { + margin-top: 0.5rem; + border-top: 1px solid #e5e7eb; + padding-top: 0.5rem; + animation: logFeedSlideIn 0.25s ease-out both; +} + +@keyframes logFeedSlideIn { + from { + opacity: 0; + max-height: 0; + } + to { + opacity: 1; + max-height: 500px; + } +} + +.step-log-feed-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 0.4rem; + margin-bottom: 0.4rem; + border-bottom: 1px solid #f3f4f6; +} + +.step-log-feed-header-left { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.step-log-feed-header-right { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.step-log-feed-title { + font-size: 0.75rem; + font-weight: 600; + color: #4b5563; +} + +.step-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; +} + +.step-log-feed-status.connected { + background: #d1fae5; + color: #059669; +} + +.step-log-feed-status.disconnected { + background: #f3f4f6; + color: #9ca3af; +} + +.step-log-feed-interrupt-btn { + background: #fef2f2; + border: 1px solid #fecaca; + color: #dc2626; + font-size: 0.72rem; + font-weight: 600; + padding: 0.2rem 0.5rem; + border-radius: 5px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.step-log-feed-interrupt-btn:hover { + background: #fee2e2; + border-color: #f87171; +} + +.step-log-feed-collapse-btn { + background: none; + border: 1px solid #e5e7eb; + color: #6b7280; + font-size: 0.75rem; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s; + padding: 0; +} + +.step-log-feed-collapse-btn:hover { + background: #f3f4f6; + color: #1f2937; + border-color: #d1d5db; +} + +/* Log content area */ +.step-log-feed-content { + max-height: 280px; + overflow-y: auto; + background: #1a1b26; + border-radius: 6px; + padding: 0.5rem; + font-family: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, Consolas, monospace; + font-size: 0.75rem; + line-height: 1.5; +} + +.step-log-feed-empty { + color: #6b7280; + font-style: italic; + padding: 1rem; + text-align: center; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} + +.step-log-feed-error { + color: #f87171; + padding: 0.25rem; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-size: 0.78rem; +} + +/* Log entries */ +.step-log-entry { + display: flex; + gap: 0.5rem; + padding: 0.1rem 0.25rem; + border-radius: 2px; +} + +.step-log-entry:hover { + background: rgba(255, 255, 255, 0.03); +} + +.step-log-entry-time { + color: #565f89; + white-space: nowrap; + flex-shrink: 0; + min-width: 5.5em; +} + +.step-log-entry-content { + color: #a9b1d6; + word-break: break-word; + white-space: pre-wrap; +} + +.step-log-entry--stderr .step-log-entry-content { + color: #f7768e; +} + +.step-log-entry--system .step-log-entry-content { + color: #7aa2f7; + font-style: italic; +} + +.step-log-entry--user .step-log-entry-content { + color: #9ece6a; +} + +.step-log-entry--user::before { + content: '> '; + color: #9ece6a; +} + +/* Message input */ +.step-log-feed-input { + display: flex; + gap: 0.35rem; + margin-top: 0.4rem; +} + +.step-log-feed-input-field { + flex: 1; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 5px; + padding: 0.35rem 0.6rem; + font-size: 0.78rem; + color: #1f2937; + outline: none; + transition: border-color 0.15s; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} + +.step-log-feed-input-field:focus { + border-color: #93c5fd; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1); +} + +.step-log-feed-input-field:disabled { + opacity: 0.5; +} + +.step-log-feed-send-btn { + background: #3b82f6; + border: none; + color: #ffffff; + font-size: 0.82rem; + padding: 0.35rem 0.65rem; + border-radius: 5px; + cursor: pointer; + transition: background 0.15s; + white-space: nowrap; +} + +.step-log-feed-send-btn:hover:not(:disabled) { + background: #2563eb; +} + +.step-log-feed-send-btn:disabled { + background: #93c5fd; + cursor: not-allowed; +} + /* ---- Responsive ---- */ @media (max-width: 640px) { .steps-diagram { padding: 0.75rem; } - .steps-diagram-card { + .steps-diagram-card-wrapper { + flex: 1 1 100%; + max-width: 100%; + } + + .steps-diagram-card-wrapper { flex: 1 1 100%; max-width: 100%; } + + .step-log-feed-content { + max-height: 200px; + } } diff --git a/frontend/src/components/document/nodes/StepsDiagramComponent.tsx b/frontend/src/components/document/nodes/StepsDiagramComponent.tsx index 606c0ab..53f860e 100644 --- a/frontend/src/components/document/nodes/StepsDiagramComponent.tsx +++ b/frontend/src/components/document/nodes/StepsDiagramComponent.tsx @@ -1,18 +1,20 @@ 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: 'Pending', + pending: 'Queued', ready: 'Ready', - running: 'Running', - completed: 'Done', + running: 'Executing', + completed: 'Fulfilled', failed: 'Failed', skipped: 'Skipped', }; @@ -23,18 +25,40 @@ function formatTime(dateStr: string): string { return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } -function StepCard({ step }: { step: DirectiveStep }) { +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}`}> - <div className="steps-diagram-card-header"> + <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> - <span className={`steps-diagram-status-badge steps-diagram-status-badge--${status}`}> - {STATUS_LABELS[status] || status} - </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 && ( + {step.description && !isExpanded && ( <p className="steps-diagram-card-desc">{step.description}</p> )} <div className="steps-diagram-card-footer"> @@ -46,20 +70,68 @@ function StepCard({ step }: { step: DirectiveStep }) { <span className="steps-diagram-card-time"> Completed {formatTime(step.completedAt)} </span> + {hasTask && ( + <button + className={`steps-diagram-card-expand-btn ${expanded ? 'steps-diagram-card-expand-btn--open' : ''}`} + onClick={() => setExpanded((v) => !v)} + title={expanded ? 'Collapse log feed' : 'Expand log feed'} + > + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <polyline points="6 9 12 15 18 9" /> + </svg> + </button> + )} + </div> + {step.description && ( + <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> </div> + + {/* Expandable log feed */} + {isExpanded && hasTask && ( + <StepLogFeed + taskId={step.taskId || step.contractId} + stepName={step.name} + stepStatus={status} + onCollapse={onCollapse} + /> + )} </div> ); } -export function StepsDiagramComponent({ directiveId }: StepsDiagramComponentProps) { +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 toggleStep = useCallback((stepId: string) => { + setExpandedSteps((prev) => { + const next = new Set(prev); + if (next.has(stepId)) { + next.delete(stepId); + } else { + next.add(stepId); + } + return next; + }); + }, []); + const fetchSteps = useCallback(async () => { try { const data: DirectiveWithSteps = await getDirective(directiveId); @@ -67,7 +139,7 @@ export function StepsDiagramComponent({ directiveId }: StepsDiagramComponentProp setDirectiveStatus(data.status || ''); setError(null); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load steps'); + setError(err instanceof Error ? err.message : 'Failed to load contracts'); } finally { setLoading(false); } @@ -86,11 +158,29 @@ export function StepsDiagramComponent({ directiveId }: StepsDiagramComponentProp 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; - const isAddingSteps = isActive && steps.length > 0 && steps.length > prevStepCountRef.current; // Group steps by orderIndex const groupedSteps: Map<number, DirectiveStep[]> = new Map(); @@ -106,12 +196,12 @@ export function StepsDiagramComponent({ directiveId }: StepsDiagramComponentProp return ( <div className="steps-diagram" contentEditable={false}> <div className="steps-diagram-header"> - <span className="steps-diagram-header-title">Steps</span> + <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 steps...</span> + <span>Loading contracts...</span> </div> </div> ); @@ -121,22 +211,22 @@ export function StepsDiagramComponent({ directiveId }: StepsDiagramComponentProp return ( <div className="steps-diagram" contentEditable={false}> <div className="steps-diagram-header"> - <span className="steps-diagram-header-title">Steps</span> + <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 steps: {error}</div> + <div className="steps-diagram-error">Failed to load contracts: {error}</div> </div> ); } return ( - <div className="steps-diagram" contentEditable={false}> + <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">Steps</span> + <span className="steps-diagram-header-title">Contract Steps</span> {totalCount > 0 && ( <span className="steps-diagram-header-count"> - {completedCount}/{totalCount} completed + {completedCount}/{totalCount} fulfilled </span> )} </div> @@ -148,12 +238,12 @@ export function StepsDiagramComponent({ directiveId }: StepsDiagramComponentProp <div className="steps-diagram-planning-dots"> <span /><span /><span /> </div> - <span>Makima is building the plan...</span> + <span>Makima is drafting contracts...</span> </div> )} {totalCount === 0 && !isBuilding && ( - <div className="steps-diagram-empty">No steps defined yet.</div> + <div className="steps-diagram-empty">No contract steps defined yet.</div> )} {totalCount > 0 && ( @@ -168,7 +258,13 @@ export function StepsDiagramComponent({ directiveId }: StepsDiagramComponentProp )} <div className="steps-diagram-group"> {groupSteps.map((step) => ( - <StepCard key={step.id} step={step} /> + <StepCard + key={step.id} + step={step} + isExpanded={expandedStepId === step.id} + onToggleExpand={() => toggleExpand(step.id)} + onCollapse={collapseExpanded} + /> ))} </div> </React.Fragment> |
