From d1fdfb140cc440664f77a24886172f9976a05a31 Mon Sep 17 00:00:00 2001 From: soryu Date: Tue, 28 Apr 2026 19:12:52 +0100 Subject: feat: revert broken directive PRs, re-implement Lexical document orchestrator (#98) * feat: soryu-co/soryu - makima: Revert broken directive PRs and verify clean build * feat: soryu-co/soryu - makima: Re-implement frontend: Lexical document editor with feature flag and base components * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Add contract blocks, expandable log rows, and interaction controls * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: End-to-end build verification and integration 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 | 4 +- .../src/components/document/nodes/StepsDiagram.css | 683 --------------------- .../document/nodes/StepsDiagramComponent.tsx | 37 -- .../components/document/nodes/StepsDiagramNode.tsx | 91 --- 9 files changed, 2 insertions(+), 1730 deletions(-) delete mode 100644 frontend/src/components/document/nodes/ContractBlock.css delete mode 100644 frontend/src/components/document/nodes/ContractBlockComponent.tsx delete mode 100644 frontend/src/components/document/nodes/ContractBlockNode.tsx delete mode 100644 frontend/src/components/document/nodes/ContractLogFeed.css delete mode 100644 frontend/src/components/document/nodes/ContractLogFeed.tsx delete mode 100644 frontend/src/components/document/nodes/StepsDiagram.css delete mode 100644 frontend/src/components/document/nodes/StepsDiagramNode.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 deleted file mode 100644 index 80edb74..0000000 --- a/frontend/src/components/document/nodes/ContractBlock.css +++ /dev/null @@ -1,123 +0,0 @@ -/* ============================================ - 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 deleted file mode 100644 index 0d9a25a..0000000 --- a/frontend/src/components/document/nodes/ContractBlockComponent.tsx +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index 86e4c9d..0000000 --- a/frontend/src/components/document/nodes/ContractBlockNode.tsx +++ /dev/null @@ -1,106 +0,0 @@ -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 deleted file mode 100644 index b5dd15d..0000000 --- a/frontend/src/components/document/nodes/ContractLogFeed.css +++ /dev/null @@ -1,346 +0,0 @@ -/* ============================================ - 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 deleted file mode 100644 index 79af91c..0000000 --- a/frontend/src/components/document/nodes/ContractLogFeed.tsx +++ /dev/null @@ -1,225 +0,0 @@ -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 && ( -
-
-