summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--frontend/src/components/VNInterface.tsx14
-rw-r--r--frontend/src/components/document/DirectiveFileTree.tsx12
-rw-r--r--frontend/src/components/document/DocumentEditor.tsx2
-rw-r--r--frontend/src/components/document/DocumentLayout.css16
-rw-r--r--frontend/src/components/document/DocumentLayout.tsx2
-rw-r--r--frontend/src/components/document/index.ts9
-rw-r--r--frontend/src/components/document/nodes/ContractBlock.css168
-rw-r--r--frontend/src/components/document/nodes/ContractBlockComponent.tsx164
-rw-r--r--frontend/src/components/document/nodes/ContractBlockNode.tsx33
-rw-r--r--frontend/src/components/document/nodes/StepLogFeed.tsx392
-rw-r--r--frontend/src/components/document/nodes/StepsDiagram.css408
-rw-r--r--frontend/src/components/document/nodes/StepsDiagramComponent.tsx114
-rw-r--r--frontend/src/main.tsx16
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">&#x1F4E6;</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">&#x1F4E6;</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>
+ &#x23F9; 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)"
+ >
+ &#x2715;
+ </button>
+ </div>
+ </div>
+
+ {/* Log content */}
+ <div className="step-log-feed-content" ref={logContainerRef}>
+ {logs.length === 0 && !error && (
+ <div className="step-log-feed-empty">
+ {isActive
+ ? 'Waiting for log output...'
+ : 'No logs available for this step.'}
</div>
)}
- {/* 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 ? '...' : '&#x27A4;'}
</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' : ''}`}>
+ &#x25B6;
+ </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([
{