diff options
| author | soryu <soryu@soryu.co> | 2026-04-28 17:31:24 +0100 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-04-28 17:31:24 +0100 |
| commit | 040085734375f874917bbce96aca193e13144467 (patch) | |
| tree | 77525c0e859c5206a26ab1049956de248d077cf1 | |
| parent | 2d44597f9c686bdca06f2e218ddd3785657c40eb (diff) | |
| parent | 956ac21d2b9adc88b439441c0487d26abca6a3b9 (diff) | |
| download | soryu-makima/directive-soryu-co-soryu---makima-19fd3e1d-v1777393823.tar.gz soryu-makima/directive-soryu-co-soryu---makima-19fd3e1d-v1777393823.zip | |
Merge remote-tracking branch 'origin/makima/soryu-co-soryu---makima--integrate-all-document-ui-3ce0f839' into makima/directive-soryu-co-soryu---makima-19fd3e1d-v1777393823makima/directive-soryu-co-soryu---makima-19fd3e1d-v1777393823
| -rw-r--r-- | frontend/src/components/VNInterface.tsx | 14 | ||||
| -rw-r--r-- | frontend/src/components/document/DirectiveFileTree.tsx | 12 | ||||
| -rw-r--r-- | frontend/src/components/document/DocumentEditor.tsx | 2 | ||||
| -rw-r--r-- | frontend/src/components/document/DocumentLayout.css | 16 | ||||
| -rw-r--r-- | frontend/src/components/document/DocumentLayout.tsx | 2 | ||||
| -rw-r--r-- | frontend/src/components/document/index.ts | 9 | ||||
| -rw-r--r-- | frontend/src/components/document/nodes/ContractBlock.css | 168 | ||||
| -rw-r--r-- | frontend/src/components/document/nodes/ContractBlockComponent.tsx | 164 | ||||
| -rw-r--r-- | frontend/src/components/document/nodes/ContractBlockNode.tsx | 33 | ||||
| -rw-r--r-- | frontend/src/components/document/nodes/StepLogFeed.tsx | 392 | ||||
| -rw-r--r-- | frontend/src/components/document/nodes/StepsDiagram.css | 408 | ||||
| -rw-r--r-- | frontend/src/components/document/nodes/StepsDiagramComponent.tsx | 114 | ||||
| -rw-r--r-- | frontend/src/main.tsx | 16 |
13 files changed, 710 insertions, 640 deletions
diff --git a/frontend/src/components/VNInterface.tsx b/frontend/src/components/VNInterface.tsx index 2da00b3..48b150a 100644 --- a/frontend/src/components/VNInterface.tsx +++ b/frontend/src/components/VNInterface.tsx @@ -110,10 +110,18 @@ export function VNInterface({ onLogout }: VNInterfaceProps) { <span className="info-value">Daemons</span> </Link> </div> + {documentUiEnabled && ( + <div className="status-item"> + <Link to="/directives" style={{ color: '#ff66cc', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '4px', fontWeight: 600 }}> + <span className="info-label">View:</span> + <span className="info-value">Directives</span> + </Link> + </div> + )} <div className="status-item"> - <Link to="/directives" style={{ color: '#ff66cc', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '4px' }}> - <span className="info-label">View:</span> - <span className="info-value">Directives</span> + <Link to="/contracts" style={{ color: documentUiEnabled ? '#8899bb' : '#66ccff', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '4px', fontSize: documentUiEnabled ? '0.85em' : undefined }}> + <span className="info-label">{documentUiEnabled ? 'Legacy:' : 'View:'}</span> + <span className="info-value">Contracts</span> </Link> </div> </div> diff --git a/frontend/src/components/document/DirectiveFileTree.tsx b/frontend/src/components/document/DirectiveFileTree.tsx index 21050ca..bacffe6 100644 --- a/frontend/src/components/document/DirectiveFileTree.tsx +++ b/frontend/src/components/document/DirectiveFileTree.tsx @@ -140,6 +140,18 @@ export function DirectiveFileTree({ selectedDirectiveId, onSelectDirective, onNe /> <span className="file-tree-doc-icon">{'\u{1F4C4}'}</span> <span className="file-tree-item-title">{directive.title || 'Untitled'}</span> + {directive.stepCounts && ( + <span className="file-tree-step-count" title="Contract steps"> + {directive.stepCounts.completed}/{ + directive.stepCounts.pending + + directive.stepCounts.ready + + directive.stepCounts.running + + directive.stepCounts.completed + + directive.stepCounts.failed + + directive.stepCounts.skipped + } + </span> + )} </button> ))} </div> diff --git a/frontend/src/components/document/DocumentEditor.tsx b/frontend/src/components/document/DocumentEditor.tsx index 4b328a5..2ef37fe 100644 --- a/frontend/src/components/document/DocumentEditor.tsx +++ b/frontend/src/components/document/DocumentEditor.tsx @@ -117,7 +117,7 @@ export default function DocumentEditor({ for (let i = 0; i < children.length; i++) { const child = children[i]; - // Skip decorator nodes when extracting text + // Skip decorator nodes (steps diagram, contract blocks) when extracting text if ($isStepsDiagramNode(child)) continue; if ($isContractBlockNode(child)) continue; diff --git a/frontend/src/components/document/DocumentLayout.css b/frontend/src/components/document/DocumentLayout.css index b18bb81..ae73e7a 100644 --- a/frontend/src/components/document/DocumentLayout.css +++ b/frontend/src/components/document/DocumentLayout.css @@ -127,9 +127,13 @@ .document-content { flex: 1; overflow-y: auto; + overflow-x: hidden; display: flex; flex-direction: column; align-items: center; + scroll-behavior: smooth; + /* Ensure expanded log feeds don't break layout */ + min-height: 0; } /* Placeholder / empty state */ @@ -328,6 +332,18 @@ .file-tree-item-title { overflow: hidden; text-overflow: ellipsis; + flex: 1; +} + +.file-tree-step-count { + margin-left: auto; + font-size: 10px; + color: #666; + background: rgba(255, 255, 255, 0.06); + border-radius: 8px; + padding: 1px 6px; + flex-shrink: 0; + white-space: nowrap; } /* Responsive: mobile */ diff --git a/frontend/src/components/document/DocumentLayout.tsx b/frontend/src/components/document/DocumentLayout.tsx index cd4ffcd..05f4190 100644 --- a/frontend/src/components/document/DocumentLayout.tsx +++ b/frontend/src/components/document/DocumentLayout.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback, useRef } from 'react' +import { useEffect, useState, useCallback, useRef, useMemo } from 'react' import { useParams, useNavigate, Link } from 'react-router-dom' import { DirectiveFileTree } from './DirectiveFileTree' import DocumentEditor from './DocumentEditor' diff --git a/frontend/src/components/document/index.ts b/frontend/src/components/document/index.ts index af9e362..3217a1b 100644 --- a/frontend/src/components/document/index.ts +++ b/frontend/src/components/document/index.ts @@ -2,3 +2,12 @@ export { default as DocumentLayout } from './DocumentLayout' export { default as DocumentEditor } from './DocumentEditor' export { DirectiveFileTree } from './DirectiveFileTree' export { default as DocumentSettings } from './DocumentSettings' + +// Lexical Nodes +export { StepsDiagramNode, $createStepsDiagramNode, $isStepsDiagramNode } from './nodes/StepsDiagramNode' +export { ContractBlockNode, $createContractBlockNode, $isContractBlockNode } from './nodes/ContractBlockNode' + +// Sub-components +export { StepsDiagramComponent } from './nodes/StepsDiagramComponent' +export { ContractBlockComponent } from './nodes/ContractBlockComponent' +export { StepLogFeed } from './nodes/StepLogFeed' diff --git a/frontend/src/components/document/nodes/ContractBlock.css b/frontend/src/components/document/nodes/ContractBlock.css index 66a2cce..80edb74 100644 --- a/frontend/src/components/document/nodes/ContractBlock.css +++ b/frontend/src/components/document/nodes/ContractBlock.css @@ -1,142 +1,110 @@ /* ============================================ - Contract Block + Contract Block - Inline contract reference ============================================ */ -.contract-block { - margin: 0.75rem 0; +.contract-block-wrapper { + margin: 1rem 0; user-select: none; } -.contract-block-container { - background: #fafbfd; +.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: 14px; + font-size: 13px; color: #374151; - overflow: hidden; + transition: box-shadow 0.2s ease, border-color 0.2s ease; + animation: contractBlockAppear 0.25s ease-out both; } -/* ---- Header ---- */ -.contract-block-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.6rem 0.85rem; - cursor: pointer; - transition: background-color 0.15s ease; +@keyframes contractBlockAppear { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } } -.contract-block-header:hover { - background: #f3f4f8; +.contract-block:hover { + border-color: #c7cce0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); } -.contract-block-header-left { - display: flex; - align-items: center; - gap: 0.5rem; - flex: 1; - min-width: 0; +.contract-block--error { + border-color: #fecaca; + background: #fef2f2; } -.contract-block-header-right { +/* Header */ +.contract-block-header { display: flex; align-items: center; - gap: 0.6rem; - flex-shrink: 0; -} - -.contract-block-chevron { - font-size: 0.55rem; - color: #9ca3af; - transition: transform 0.2s ease; - flex-shrink: 0; -} - -.contract-block-chevron--open { - transform: rotate(90deg); + gap: 0.5rem; } .contract-block-icon { - font-size: 0.9rem; + font-size: 1rem; flex-shrink: 0; } .contract-block-name { font-weight: 600; - font-size: 0.85rem; + font-size: 0.88rem; color: #1f2937; + flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.contract-block-status-badge { +.contract-block-phase-badge { font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; - padding: 0.12rem 0.45rem; - border-radius: 9px; + padding: 0.1rem 0.4rem; + border-radius: 8px; white-space: nowrap; + flex-shrink: 0; } -.contract-block-id { - font-size: 0.7rem; - color: #9ca3af; - font-family: 'SF Mono', SFMono-Regular, ui-monospace, Menlo, monospace; -} - -/* ---- Details (expanded) ---- */ -.contract-block-details { - padding: 0.65rem 0.85rem 0.75rem; - border-top: 1px solid #e5e7eb; - background: #ffffff; - animation: contractDetailsAppear 0.2s ease-out; -} - -@keyframes contractDetailsAppear { - from { - opacity: 0; - transform: translateY(-4px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.contract-block-detail-row { - display: flex; - gap: 0.75rem; - padding: 0.3rem 0; - font-size: 0.8rem; - line-height: 1.45; +.contract-block-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; } -.contract-block-detail-row + .contract-block-detail-row { - border-top: 1px solid #f3f4f6; +/* Meta */ +.contract-block-meta { + margin-top: 0.3rem; + padding-left: 1.5rem; } -.contract-block-detail-label { - color: #6b7280; - font-weight: 500; - min-width: 65px; - flex-shrink: 0; +.contract-block-type { + font-size: 0.75rem; + color: #9ca3af; + font-style: italic; } -.contract-block-detail-value { - color: #374151; - flex: 1; - min-width: 0; +.contract-block-error-msg { + margin-top: 0.25rem; + font-size: 0.78rem; + color: #dc2626; + padding-left: 1.5rem; } -/* ---- Loading ---- */ +/* Loading state */ .contract-block-loading { display: flex; align-items: center; gap: 0.5rem; - padding: 0.6rem 0.85rem; + padding: 0.4rem 0; color: #9ca3af; font-size: 0.82rem; } @@ -147,33 +115,9 @@ border: 2px solid #e5e7eb; border-top-color: #6b7280; border-radius: 50%; - animation: contractSpin 0.8s linear infinite; + animation: spin 0.8s linear infinite; } -@keyframes contractSpin { +@keyframes spin { to { transform: rotate(360deg); } } - -/* ---- Error ---- */ -.contract-block-error { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.55rem 0.85rem; - background: #fef2f2; - color: #dc2626; - font-size: 0.8rem; -} - -.contract-block-error-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - background: #fee2e2; - border-radius: 50%; - font-size: 0.7rem; - font-weight: 700; - flex-shrink: 0; -} diff --git a/frontend/src/components/document/nodes/ContractBlockComponent.tsx b/frontend/src/components/document/nodes/ContractBlockComponent.tsx index 544b7a5..0d9a25a 100644 --- a/frontend/src/components/document/nodes/ContractBlockComponent.tsx +++ b/frontend/src/components/document/nodes/ContractBlockComponent.tsx @@ -1,73 +1,70 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState } from 'react'; import './ContractBlock.css'; interface ContractBlockComponentProps { contractId: string; + contractName: string; } interface ContractInfo { id: string; name: string; status: string; - goal?: string; - createdAt?: string; - updatedAt?: 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> = { - pending: '#6b7280', - ready: '#2563eb', - running: '#d97706', - active: '#d97706', - completed: '#059669', - done: '#059669', - failed: '#dc2626', - skipped: '#9ca3af', - paused: '#6b7280', + active: '#10b981', + running: '#10b981', + idle: '#f59e0b', + paused: '#f59e0b', + completed: '#10b981', + failed: '#ef4444', + archived: '#6b7280', }; -export function ContractBlockComponent({ contractId }: ContractBlockComponentProps) { +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); - const [expanded, setExpanded] = useState(false); - const fetchContract = useCallback(async () => { - try { - const response = await fetch(`/api/v1/mesh/tasks/${contractId}`); - if (!response.ok) { - throw new Error(`Failed to fetch contract: ${response.statusText}`); + 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); } - const data = await response.json(); - setContract({ - id: data.id || contractId, - name: data.name || data.title || 'Unnamed Contract', - status: data.status || 'pending', - goal: data.goal || data.description || '', - createdAt: data.created_at || data.createdAt, - updatedAt: data.updated_at || data.updatedAt, - }); - setError(null); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load contract'); - } finally { - setLoading(false); } - }, [contractId]); - useEffect(() => { fetchContract(); - const interval = setInterval(fetchContract, 10000); - return () => clearInterval(interval); - }, [fetchContract]); - - const toggleExpanded = useCallback(() => { - setExpanded(prev => !prev); - }, []); + return () => { cancelled = true; }; + }, [contractId]); if (loading) { return ( - <div className="contract-block-container" contentEditable={false}> + <div className="contract-block" contentEditable={false}> <div className="contract-block-loading"> <div className="contract-block-spinner" /> <span>Loading contract...</span> @@ -78,66 +75,41 @@ export function ContractBlockComponent({ contractId }: ContractBlockComponentPro if (error) { return ( - <div className="contract-block-container" contentEditable={false}> - <div className="contract-block-error"> - <span className="contract-block-error-icon">!</span> - <span>Contract {contractId.slice(0, 8)}: {error}</span> + <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> ); } - if (!contract) return null; - - const statusColor = STATUS_COLORS[contract.status.toLowerCase()] || '#6b7280'; + 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-container" contentEditable={false}> - <div className="contract-block-header" onClick={toggleExpanded}> - <div className="contract-block-header-left"> - <span className={`contract-block-chevron ${expanded ? 'contract-block-chevron--open' : ''}`}> - {'\u25B6'} - </span> - <span className="contract-block-icon">{'\uD83D\uDCC4'}</span> - <span className="contract-block-name">{contract.name}</span> - </div> - <div className="contract-block-header-right"> - <span - className="contract-block-status-badge" - style={{ backgroundColor: `${statusColor}18`, color: statusColor }} - > - {contract.status} - </span> - <span className="contract-block-id" title={contract.id}> - {contract.id.slice(0, 8)} - </span> - </div> + <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> - - {expanded && ( - <div className="contract-block-details"> - {contract.goal && ( - <div className="contract-block-detail-row"> - <span className="contract-block-detail-label">Goal</span> - <span className="contract-block-detail-value">{contract.goal}</span> - </div> - )} - {contract.createdAt && ( - <div className="contract-block-detail-row"> - <span className="contract-block-detail-label">Created</span> - <span className="contract-block-detail-value"> - {new Date(contract.createdAt).toLocaleString()} - </span> - </div> - )} - {contract.updatedAt && ( - <div className="contract-block-detail-row"> - <span className="contract-block-detail-label">Updated</span> - <span className="contract-block-detail-value"> - {new Date(contract.updatedAt).toLocaleString()} - </span> - </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 index 8e463f2..86e4c9d 100644 --- a/frontend/src/components/document/nodes/ContractBlockNode.tsx +++ b/frontend/src/components/document/nodes/ContractBlockNode.tsx @@ -12,29 +12,32 @@ 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.__key); + return new ContractBlockNode(node.__contractId, node.__contractName, node.__key); } - constructor(contractId: string, key?: NodeKey) { + 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'; + div.className = 'contract-block-wrapper'; return div; } @@ -43,7 +46,12 @@ export class ContractBlockNode extends DecoratorNode<JSX.Element> { } decorate(): JSX.Element { - return <ContractBlockComponent contractId={this.__contractId} />; + return ( + <ContractBlockComponent + contractId={this.__contractId} + contractName={this.__contractName} + /> + ); } exportJSON(): SerializedContractBlockNode { @@ -51,12 +59,16 @@ export class ContractBlockNode extends DecoratorNode<JSX.Element> { ...super.exportJSON(), type: 'contract-block', contractId: this.__contractId, + contractName: this.__contractName, version: 1, }; } static importJSON(serializedNode: SerializedContractBlockNode): ContractBlockNode { - return $createContractBlockNode(serializedNode.contractId); + return $createContractBlockNode( + serializedNode.contractId, + serializedNode.contractName + ); } isInline(): boolean { @@ -73,15 +85,18 @@ export class ContractBlockNode extends DecoratorNode<JSX.Element> { exportDOM(): DOMExportOutput { const element = document.createElement('div'); - element.className = 'contract-block'; + element.className = 'contract-block-wrapper'; element.setAttribute('data-contract-id', this.__contractId); - element.textContent = '[Contract Block]'; + element.textContent = `[Contract: ${this.__contractName}]`; return { element }; } } -export function $createContractBlockNode(contractId: string): ContractBlockNode { - return new ContractBlockNode(contractId); +export function $createContractBlockNode( + contractId: string, + contractName: string +): ContractBlockNode { + return new ContractBlockNode(contractId, contractName); } export function $isContractBlockNode( diff --git a/frontend/src/components/document/nodes/StepLogFeed.tsx b/frontend/src/components/document/nodes/StepLogFeed.tsx index 3e4088e..0357de8 100644 --- a/frontend/src/components/document/nodes/StepLogFeed.tsx +++ b/frontend/src/components/document/nodes/StepLogFeed.tsx @@ -1,213 +1,277 @@ -import React, { useState, useRef, useEffect, useCallback } from 'react'; -import { sendTaskMessage, stopTask, continueTask } from '../../../services/directiveApi'; -import { useToast } from '../Toast'; - -export interface LogEntry { - id: string; - text: string; - timestamp?: string; - type?: 'system' | 'user' | 'error'; -} +import React, { useEffect, useRef, useState, useCallback } from 'react'; interface StepLogFeedProps { taskId: string; - taskStatus: string; - logs: LogEntry[]; - onMessageSent?: (message: string) => void; - onStatusChange?: () => void; + stepName: string; + stepStatus: string; + onCollapse: () => void; +} + +interface LogEntry { + timestamp: string; + content: string; + type: 'stdout' | 'stderr' | 'system' | 'user'; } -export function StepLogFeed({ - taskId, - taskStatus, - logs, - onMessageSent, - onStatusChange, -}: StepLogFeedProps) { +/** + * 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 [showStopConfirm, setShowStopConfirm] = 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 confirmRef = useRef<HTMLDivElement>(null); - const { addToast } = useToast(); - const isRunning = ['running', 'active'].includes(taskStatus.toLowerCase()); - const isStopped = ['completed', 'paused', 'stopped', 'failed'].includes(taskStatus.toLowerCase()); + const isActive = ['running', 'starting'].includes(stepStatus.toLowerCase()); - // Auto-scroll to bottom when new logs appear + // Auto-scroll to bottom when new logs arrive useEffect(() => { logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [logs.length]); + }, [logs]); - // Close confirm popover on outside click + // Connect to WebSocket for live streaming useEffect(() => { - if (!showStopConfirm) return; - const handler = (e: MouseEvent) => { - if (confirmRef.current && !confirmRef.current.contains(e.target as Node)) { - setShowStopConfirm(false); + 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; } }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [showStopConfirm]); + }, [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 () => { - const trimmed = message.trim(); - if (!trimmed || sending) return; + if (!message.trim() || !taskId || sending) return; setSending(true); - // Optimistic: show the message immediately - onMessageSent?.(trimmed); - setMessage(''); - try { - await sendTaskMessage(taskId, trimmed); - addToast('Message sent', 'success'); + 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) { - addToast( - err instanceof Error ? err.message : 'Failed to send message', - 'error', - ); + setError(err instanceof Error ? err.message : 'Failed to send message'); } finally { setSending(false); - inputRef.current?.focus(); } - }, [message, sending, taskId, addToast, onMessageSent]); + }, [message, taskId, sending]); - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSendMessage(); - } - }, - [handleSendMessage], - ); + 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]); - const handleStop = useCallback(async () => { - setShowStopConfirm(false); + // Interrupt the running task + const handleInterrupt = useCallback(async () => { + if (!taskId) return; try { - await stopTask(taskId); - addToast('Task interrupted', 'success'); - onStatusChange?.(); + // 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) { - addToast( - err instanceof Error ? err.message : 'Failed to stop task', - 'error', - ); + setError(err instanceof Error ? err.message : 'Failed to interrupt'); } - }, [taskId, addToast, onStatusChange]); + }, [taskId]); - const handleContinue = useCallback(async () => { + const formatTimestamp = (ts: string) => { try { - await continueTask(taskId); - addToast('Task resumed', 'success'); - onStatusChange?.(); - } catch (err) { - addToast( - err instanceof Error ? err.message : 'Failed to continue task', - 'error', - ); + return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } catch { + return ''; } - }, [taskId, addToast, onStatusChange]); + }; return ( <div className="step-log-feed"> - {/* Log entries */} - <div className="step-log-feed-entries"> - {logs.length === 0 && ( - <div className="step-log-feed-empty">No log entries yet.</div> - )} - {logs.map((entry) => ( - <div - key={entry.id} - className={`step-log-feed-entry step-log-feed-entry--${entry.type || 'system'}`} - > - {entry.timestamp && ( - <span className="step-log-feed-entry-time">{entry.timestamp}</span> - )} - <span className="step-log-feed-entry-text">{entry.text}</span> - </div> - ))} - <div ref={logsEndRef} /> - </div> - - {/* Input bar */} - <div className="step-log-feed-input-bar"> - <input - ref={inputRef} - className="step-log-feed-input" - type="text" - placeholder={isRunning ? 'Send a message...' : 'Task not running'} - value={message} - onChange={(e) => setMessage(e.target.value)} - onKeyDown={handleKeyDown} - disabled={sending} - /> - <button - className="step-log-feed-send-btn" - onClick={handleSendMessage} - disabled={sending || !message.trim()} - title="Send message" - > - {sending ? ( - <span className="step-log-feed-spinner" /> - ) : ( - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> - <line x1="22" y1="2" x2="11" y2="13" /> - <polygon points="22 2 15 22 11 13 2 9 22 2" /> - </svg> - )} - </button> - - {/* Interrupt button (visible when running) */} - {isRunning && ( - <div className="step-log-feed-stop-wrapper" ref={confirmRef}> + {/* 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-stop-btn" - onClick={() => setShowStopConfirm((v) => !v)} - title="Interrupt task" + className="step-log-feed-interrupt-btn" + onClick={handleInterrupt} + title="Interrupt this task" > - <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"> - <rect x="4" y="4" width="16" height="16" rx="2" /> - </svg> + ⏹ Interrupt </button> - {showStopConfirm && ( - <div className="step-log-feed-confirm-popover"> - <span>Interrupt this task?</span> - <div className="step-log-feed-confirm-actions"> - <button - className="step-log-feed-confirm-yes" - onClick={handleStop} - > - Yes, stop - </button> - <button - className="step-log-feed-confirm-no" - onClick={() => setShowStopConfirm(false)} - > - Cancel - </button> - </div> - </div> - )} + )} + <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> )} - {/* Continue button (visible when stopped/completed/paused) */} - {isStopped && ( + {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-continue-btn" - onClick={handleContinue} - title="Continue task" + className="step-log-feed-send-btn" + onClick={handleSendMessage} + disabled={!message.trim() || sending} + title="Send message (Enter)" > - <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"> - <polygon points="5 3 19 12 5 21 5 3" /> - </svg> + {sending ? '...' : '➤'} </button> - )} - </div> + </div> + )} </div> ); } diff --git a/frontend/src/components/document/nodes/StepsDiagram.css b/frontend/src/components/document/nodes/StepsDiagram.css index 840d0c9..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 ---- */ @@ -104,14 +110,15 @@ 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; } -/* When inside a card-wrapper, card takes full width of wrapper */ -.steps-diagram-card-wrapper .steps-diagram-card { - flex: none; - max-width: none; +.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 { @@ -150,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; @@ -158,6 +181,7 @@ text-overflow: ellipsis; white-space: nowrap; flex: 1; + transition: color 0.15s; } .steps-diagram-card-desc { @@ -202,26 +226,21 @@ color: #6b7280; } -/* ---- Card header right section ---- */ -.steps-diagram-card-header-right { - display: flex; - align-items: center; - gap: 0.35rem; - flex-shrink: 0; +/* ---- Expand Icon ---- */ +.steps-diagram-expand-icon { + font-size: 0.6rem; + color: #9ca3af; + transition: transform 0.2s ease, color 0.15s; + display: inline-block; } -/* ---- Chevron ---- */ -.steps-diagram-card-chevron { - display: inline-flex; - align-items: center; - justify-content: center; - color: #9ca3af; - transition: transform 0.25s ease, color 0.2s ease; +.steps-diagram-expand-icon.expanded { + transform: rotate(90deg); + color: #3b82f6; } -.steps-diagram-card-chevron--open { - transform: rotate(180deg); - color: #6b7280; +.steps-diagram-card-header--clickable:hover .steps-diagram-expand-icon { + color: #3b82f6; } /* ---- Status Badge ---- */ @@ -416,271 +435,230 @@ } /* ============================================ - Step Log Feed (expandable per-card) + Step Log Feed (Expandable) ============================================ */ .step-log-feed { - display: flex; - flex-direction: column; - background: #1a1d23; - border-radius: 0 0 8px 8px; - border: 1px solid #2d3139; - border-top: none; - margin-top: -1px; - overflow: hidden; - max-height: 320px; + margin-top: 0.5rem; + border-top: 1px solid #e5e7eb; + padding-top: 0.5rem; + animation: logFeedSlideIn 0.25s ease-out both; } -.step-log-feed-entries { - flex: 1 1 auto; - overflow-y: auto; - padding: 0.5rem 0.65rem; - min-height: 60px; - max-height: 240px; - font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; - font-size: 0.75rem; - line-height: 1.55; +@keyframes logFeedSlideIn { + from { + opacity: 0; + max-height: 0; + } + to { + opacity: 1; + max-height: 500px; + } } -.step-log-feed-empty { - color: #6b7280; - font-style: italic; - padding: 0.5rem 0; +.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-entry { +.step-log-feed-header-left { display: flex; + align-items: center; gap: 0.5rem; - padding: 1px 0; - word-break: break-word; -} - -.step-log-feed-entry--system .step-log-feed-entry-text { - color: #9ca3af; -} - -.step-log-feed-entry--user .step-log-feed-entry-text { - color: #60a5fa; -} - -.step-log-feed-entry--user::before { - content: '\25B6'; - color: #60a5fa; - font-size: 0.6rem; - margin-top: 3px; - flex-shrink: 0; } -.step-log-feed-entry--error .step-log-feed-entry-text { - color: #f87171; +.step-log-feed-header-right { + display: flex; + align-items: center; + gap: 0.35rem; } -.step-log-feed-entry-time { +.step-log-feed-title { + font-size: 0.75rem; + font-weight: 600; color: #4b5563; - flex-shrink: 0; - min-width: 4.5em; } -.step-log-feed-entry-text { - flex: 1; -} - -/* ---- Input Bar ---- */ -.step-log-feed-input-bar { - display: flex; - align-items: center; - gap: 0.35rem; - padding: 0.4rem 0.5rem; - background: #14161a; - border-top: 1px solid #2d3139; - flex-shrink: 0; +.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-input { - flex: 1; - background: #1e2028; - border: 1px solid #2d3139; - border-radius: 5px; - padding: 0.35rem 0.55rem; - color: #e5e7eb; - font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; - font-size: 0.78rem; - outline: none; - transition: border-color 0.15s; +.step-log-feed-status.connected { + background: #d1fae5; + color: #059669; } -.step-log-feed-input:focus { - border-color: #4b5efc; +.step-log-feed-status.disconnected { + background: #f3f4f6; + color: #9ca3af; } -.step-log-feed-input:disabled { - opacity: 0.5; - cursor: not-allowed; +.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-input::placeholder { - color: #4b5563; +.step-log-feed-interrupt-btn:hover { + background: #fee2e2; + border-color: #f87171; } -/* ---- Send Button ---- */ -.step-log-feed-send-btn { +.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; - width: 30px; - height: 30px; - border: none; - border-radius: 5px; - background: #4b5efc; - color: #fff; + border-radius: 4px; cursor: pointer; - transition: background 0.15s, opacity 0.15s; - flex-shrink: 0; + transition: all 0.15s; + padding: 0; } -.step-log-feed-send-btn:hover:not(:disabled) { - background: #3b4ef0; +.step-log-feed-collapse-btn:hover { + background: #f3f4f6; + color: #1f2937; + border-color: #d1d5db; } -.step-log-feed-send-btn:disabled { - opacity: 0.35; - cursor: not-allowed; +/* 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-spinner { - display: inline-block; - width: 12px; - height: 12px; - border: 2px solid rgba(255,255,255,0.3); - border-top-color: #fff; - border-radius: 50%; - animation: spin 0.7s linear infinite; +.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; } -/* ---- Stop / Interrupt Button ---- */ -.step-log-feed-stop-wrapper { - position: relative; +.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; } -.step-log-feed-stop-btn { +/* Log entries */ +.step-log-entry { display: flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - border: none; - border-radius: 5px; - background: #dc2626; - color: #fff; - cursor: pointer; - transition: background 0.15s; - flex-shrink: 0; + gap: 0.5rem; + padding: 0.1rem 0.25rem; + border-radius: 2px; } -.step-log-feed-stop-btn:hover { - background: #b91c1c; +.step-log-entry:hover { + background: rgba(255, 255, 255, 0.03); } -/* ---- Confirm Popover ---- */ -.step-log-feed-confirm-popover { - position: absolute; - bottom: calc(100% + 6px); - right: 0; - background: #1e2028; - border: 1px solid #374151; - border-radius: 8px; - padding: 0.6rem 0.75rem; - font-size: 0.78rem; - color: #e5e7eb; +.step-log-entry-time { + color: #565f89; white-space: nowrap; - box-shadow: 0 4px 16px rgba(0,0,0,0.4); - z-index: 20; + flex-shrink: 0; + min-width: 5.5em; } -.step-log-feed-confirm-popover span { - display: block; - margin-bottom: 0.45rem; - font-weight: 500; +.step-log-entry-content { + color: #a9b1d6; + word-break: break-word; + white-space: pre-wrap; } -.step-log-feed-confirm-actions { - display: flex; - gap: 0.4rem; +.step-log-entry--stderr .step-log-entry-content { + color: #f7768e; } -.step-log-feed-confirm-yes { - padding: 0.25rem 0.6rem; - border: none; - border-radius: 4px; - background: #dc2626; - color: #fff; - font-size: 0.72rem; - font-weight: 600; - cursor: pointer; +.step-log-entry--system .step-log-entry-content { + color: #7aa2f7; + font-style: italic; } -.step-log-feed-confirm-yes:hover { - background: #b91c1c; +.step-log-entry--user .step-log-entry-content { + color: #9ece6a; } -.step-log-feed-confirm-no { - padding: 0.25rem 0.6rem; - border: 1px solid #374151; - border-radius: 4px; - background: transparent; - color: #9ca3af; - font-size: 0.72rem; - cursor: pointer; +.step-log-entry--user::before { + content: '> '; + color: #9ece6a; } -.step-log-feed-confirm-no:hover { - background: #2d3139; +/* Message input */ +.step-log-feed-input { + display: flex; + gap: 0.35rem; + margin-top: 0.4rem; } -/* ---- Continue Button ---- */ -.step-log-feed-continue-btn { - display: flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - border: none; +.step-log-feed-input-field { + flex: 1; + background: #f9fafb; + border: 1px solid #e5e7eb; border-radius: 5px; - background: #059669; - color: #fff; - cursor: pointer; - transition: background 0.15s; - flex-shrink: 0; + 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-continue-btn:hover { - background: #047857; +.step-log-feed-input-field:focus { + border-color: #93c5fd; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1); } -/* ---- Expand toggle on card ---- */ -.steps-diagram-card-expand-btn { - background: none; - border: none; - cursor: pointer; - color: #9ca3af; - padding: 2px; - display: flex; - align-items: center; - transition: color 0.15s, transform 0.2s; - flex-shrink: 0; +.step-log-feed-input-field:disabled { + opacity: 0.5; } -.steps-diagram-card-expand-btn:hover { - color: #e5e7eb; +.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; } -.steps-diagram-card-expand-btn--open { - transform: rotate(180deg); +.step-log-feed-send-btn:hover:not(:disabled) { + background: #2563eb; } -/* Sending indicator */ -.step-log-feed-entry--sending { - opacity: 0.55; +.step-log-feed-send-btn:disabled { + background: #93c5fd; + cursor: not-allowed; } /* ---- Responsive ---- */ @@ -698,4 +676,8 @@ 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 7ed5312..53f860e 100644 --- a/frontend/src/components/document/nodes/StepsDiagramComponent.tsx +++ b/frontend/src/components/document/nodes/StepsDiagramComponent.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useRef, useCallback } from 'react'; import { getDirective, DirectiveStep, DirectiveWithSteps } from '../../../services/directiveApi'; -import { StepLogFeed, LogEntry } from './StepLogFeed'; +import { StepLogFeed } from './StepLogFeed'; import './StepsDiagram.css'; interface StepsDiagramComponentProps { @@ -25,32 +25,51 @@ function formatTime(dateStr: string): string { return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } -function StepCard({ step, onStatusChange }: { step: DirectiveStep; onStatusChange?: () => void }) { +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 [expanded, setExpanded] = useState(false); - const [localLogs, setLocalLogs] = useState<LogEntry[]>([]); - - const taskId = step.taskId || step.contractId || ''; - const hasTask = Boolean(taskId); - - const handleMessageSent = useCallback((text: string) => { - const entry: LogEntry = { - id: `user-${Date.now()}`, - text, - timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), - type: 'user', - }; - setLocalLogs((prev) => [...prev, entry]); - }, []); + const hasTask = !!step.taskId || !!step.contractId; + const canExpand = hasTask && ['running', 'completed', 'failed'].includes(status); return ( - <div> - <div className={`steps-diagram-card steps-diagram-card--${status}`}> - <div className="steps-diagram-card-header"> - <span className="steps-diagram-card-name">{step.name}</span> + <div className={`steps-diagram-card steps-diagram-card--${status} ${isExpanded ? 'steps-diagram-card--expanded' : ''}`}> + <div + className={`steps-diagram-card-header ${canExpand ? 'steps-diagram-card-header--clickable' : ''}`} + onClick={canExpand ? onToggleExpand : undefined} + role={canExpand ? 'button' : undefined} + tabIndex={canExpand ? 0 : undefined} + onKeyDown={canExpand ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggleExpand(); } } : undefined} + > + <span className="steps-diagram-card-name">{step.name}</span> + <div className="steps-diagram-card-header-right"> <span className={`steps-diagram-status-badge steps-diagram-status-badge--${status}`}> {STATUS_LABELS[status] || status} </span> + {canExpand && ( + <span className={`steps-diagram-expand-icon ${isExpanded ? 'expanded' : ''}`}> + ▶ + </span> + )} + </div> + </div> + {step.description && !isExpanded && ( + <p className="steps-diagram-card-desc">{step.description}</p> + )} + <div className="steps-diagram-card-footer"> + <span className="steps-diagram-card-index">#{step.orderIndex}</span> + {status === 'running' && ( + <span className="steps-diagram-card-progress">In progress...</span> + )} + {status === 'completed' && step.completedAt && ( + <span className="steps-diagram-card-time"> + Completed {formatTime(step.completedAt)} + </span> {hasTask && ( <button className={`steps-diagram-card-expand-btn ${expanded ? 'steps-diagram-card-expand-btn--open' : ''}`} @@ -78,13 +97,14 @@ function StepCard({ step, onStatusChange }: { step: DirectiveStep; onStatusChang )} </div> </div> - {expanded && hasTask && ( + + {/* Expandable log feed */} + {isExpanded && hasTask && ( <StepLogFeed - taskId={taskId} - taskStatus={status} - logs={localLogs} - onMessageSent={handleMessageSent} - onStatusChange={onStatusChange} + taskId={step.taskId || step.contractId} + stepName={step.name} + stepStatus={status} + onCollapse={onCollapse} /> )} </div> @@ -96,7 +116,7 @@ export function StepsDiagramComponent({ directiveId, onExpandContract }: StepsDi const [directiveStatus, setDirectiveStatus] = useState<string>(''); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); - const [expandedSteps, setExpandedSteps] = useState<Set<string>>(new Set()); + const [expandedStepId, setExpandedStepId] = useState<string | null>(null); const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const prevStepCountRef = useRef(0); @@ -138,11 +158,29 @@ export function StepsDiagramComponent({ directiveId, onExpandContract }: StepsDi 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(); @@ -158,7 +196,7 @@ export function StepsDiagramComponent({ directiveId, onExpandContract }: StepsDi return ( <div className="steps-diagram" contentEditable={false}> <div className="steps-diagram-header"> - <span className="steps-diagram-header-title">Contracts</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"> @@ -173,7 +211,7 @@ export function StepsDiagramComponent({ directiveId, onExpandContract }: StepsDi return ( <div className="steps-diagram" contentEditable={false}> <div className="steps-diagram-header"> - <span className="steps-diagram-header-title">Contracts</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 contracts: {error}</div> @@ -182,10 +220,10 @@ export function StepsDiagramComponent({ directiveId, onExpandContract }: StepsDi } 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">Contracts</span> + <span className="steps-diagram-header-title">Contract Steps</span> {totalCount > 0 && ( <span className="steps-diagram-header-count"> {completedCount}/{totalCount} fulfilled @@ -205,7 +243,7 @@ export function StepsDiagramComponent({ directiveId, onExpandContract }: StepsDi )} {totalCount === 0 && !isBuilding && ( - <div className="steps-diagram-empty">No contracts defined yet.</div> + <div className="steps-diagram-empty">No contract steps defined yet.</div> )} {totalCount > 0 && ( @@ -220,7 +258,13 @@ export function StepsDiagramComponent({ directiveId, onExpandContract }: StepsDi )} <div className="steps-diagram-group"> {groupSteps.map((step) => ( - <StepCard key={step.id} step={step} onStatusChange={fetchSteps} /> + <StepCard + key={step.id} + step={step} + isExpanded={expandedStepId === step.id} + onToggleExpand={() => toggleExpand(step.id)} + onCollapse={collapseExpanded} + /> ))} </div> </React.Fragment> diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e67be02..9527d8f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -9,13 +9,17 @@ import './styles/pc98.css' import './styles/mobile.css' // Route configuration: -// - /directives - Document-based directive management (primary view) -// - /directives/:id - View a specific directive document -// - /daemons - List all daemons -// - /daemons/:id - View daemon details +// Primary (Document UI - when feature flag enabled): +// - /directives - Document layout with file tree sidebar and Lexical editor +// - /directives/:id - Open a specific directive in the document editor // -// Note: Standalone contract routes (/contracts, /contracts/:id) have been removed. -// Contracts now only exist within directives as steps. +// Legacy (Contract UI - kept for backward compatibility): +// - /contracts - List all contracts +// - /contracts/:id - View contract details with tabs (including Files tab) +// - /contracts/:contractId/files/:fileId - View a specific file within contract context +// +// Note: When Document UI is enabled via Settings, /directives is the primary interface. +// The /contracts routes remain available as a legacy fallback. const router = createBrowserRouter([ { |
