summaryrefslogtreecommitdiff
path: root/frontend/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components')
-rw-r--r--frontend/src/components/ConfigModal.tsx35
-rw-r--r--frontend/src/components/VNInterface.tsx50
-rw-r--r--frontend/src/components/document/AutoSavePlugin.tsx140
-rw-r--r--frontend/src/components/document/ContextMenu.css79
-rw-r--r--frontend/src/components/document/ContextMenu.tsx98
-rw-r--r--frontend/src/components/document/DirectiveFileTree.tsx166
-rw-r--r--frontend/src/components/document/DocumentEditor.css246
-rw-r--r--frontend/src/components/document/DocumentEditor.tsx236
-rw-r--r--frontend/src/components/document/DocumentLayout.css363
-rw-r--r--frontend/src/components/document/DocumentLayout.tsx316
-rw-r--r--frontend/src/components/document/DocumentSettings.tsx76
-rw-r--r--frontend/src/components/document/EditorTheme.ts30
-rw-r--r--frontend/src/components/document/Toast.css100
-rw-r--r--frontend/src/components/document/Toast.tsx97
-rw-r--r--frontend/src/components/document/index.ts1
-rw-r--r--frontend/src/components/document/nodes/ContractBlock.css123
-rw-r--r--frontend/src/components/document/nodes/ContractBlockComponent.tsx117
-rw-r--r--frontend/src/components/document/nodes/ContractBlockNode.tsx106
-rw-r--r--frontend/src/components/document/nodes/ContractLogFeed.css346
-rw-r--r--frontend/src/components/document/nodes/ContractLogFeed.tsx225
-rw-r--r--frontend/src/components/document/nodes/StepLogFeed.tsx4
-rw-r--r--frontend/src/components/document/nodes/StepsDiagram.css683
-rw-r--r--frontend/src/components/document/nodes/StepsDiagramComponent.tsx37
-rw-r--r--frontend/src/components/document/nodes/StepsDiagramNode.tsx91
24 files changed, 26 insertions, 3739 deletions
diff --git a/frontend/src/components/ConfigModal.tsx b/frontend/src/components/ConfigModal.tsx
index 9746e4e..e7b1f9f 100644
--- a/frontend/src/components/ConfigModal.tsx
+++ b/frontend/src/components/ConfigModal.tsx
@@ -1,7 +1,4 @@
import React from 'react'
-import { useStore } from '@nanostores/react'
-import { Link } from 'react-router-dom'
-import { documentUiEnabledStore, setDocumentUiEnabled } from '../stores'
type Props = {
isOpen: boolean
@@ -11,8 +8,6 @@ type Props = {
}
export const ConfigModal: React.FC<Props> = ({ isOpen, onClose, skipIntro, onSkipIntroChange }) => {
- const documentUiEnabled = useStore(documentUiEnabledStore)
-
if (!isOpen) return null
return (
@@ -20,9 +15,9 @@ export const ConfigModal: React.FC<Props> = ({ isOpen, onClose, skipIntro, onSki
<div className="config-modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>Configuration</h2>
- <button className="close-btn" onClick={onClose}>{'\u00D7'}</button>
+ <button className="close-btn" onClick={onClose}>×</button>
</div>
-
+
<div className="modal-content">
<div className="config-option">
<label className="config-label">
@@ -38,32 +33,8 @@ export const ConfigModal: React.FC<Props> = ({ isOpen, onClose, skipIntro, onSki
Skip the loading screen animation on startup
</div>
</div>
-
- <div className="config-option" style={{ marginTop: '16px' }}>
- <label className="config-label">
- <input
- type="checkbox"
- checked={documentUiEnabled}
- onChange={e => setDocumentUiEnabled(e.target.checked)}
- className="config-checkbox"
- />
- <span className="config-text">Enable Document UI (Experimental)</span>
- </label>
- <div className="config-description">
- Replace the directive management interface with an interactive document editor. This is a proof of concept.
- </div>
- {documentUiEnabled && (
- <Link
- to="/directives"
- style={{ display: 'inline-block', marginTop: '8px', color: '#ff66cc', fontSize: '0.9em' }}
- onClick={onClose}
- >
- Open Directives Editor {'\u2192'}
- </Link>
- )}
- </div>
</div>
-
+
<div className="modal-footer">
<button className="modal-btn" onClick={onClose}>Close</button>
</div>
diff --git a/frontend/src/components/VNInterface.tsx b/frontend/src/components/VNInterface.tsx
index 48b150a..0a77f39 100644
--- a/frontend/src/components/VNInterface.tsx
+++ b/frontend/src/components/VNInterface.tsx
@@ -9,11 +9,11 @@ import {
showSettingsModalStore,
isVisibleStore,
yenBalanceStore,
- documentUiEnabledStore,
+ documentEditorEnabledStore,
toggleStandby,
toggleShowChoices,
updateTime,
- setDocumentUiEnabled,
+ setDocumentEditorEnabled
} from '../stores'
interface VNInterfaceProps {
@@ -28,7 +28,7 @@ export function VNInterface({ onLogout }: VNInterfaceProps) {
const showSettingsModal = useStore(showSettingsModalStore)
const isVisible = useStore(isVisibleStore)
const yenBalance = useStore(yenBalanceStore)
- const documentUiEnabled = useStore(documentUiEnabledStore)
+ const documentEditorEnabled = useStore(documentEditorEnabledStore)
// Fade in effect on mount
useEffect(() => {
@@ -110,20 +110,20 @@ export function VNInterface({ onLogout }: VNInterfaceProps) {
<span className="info-value">Daemons</span>
</Link>
</div>
- {documentUiEnabled && (
+ <div className="status-item">
+ <Link to="/contracts" style={{ color: '#66ccff', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '4px' }}>
+ <span className="info-label">View:</span>
+ <span className="info-value">Contracts</span>
+ </Link>
+ </div>
+ {documentEditorEnabled && (
<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>
+ <Link to="/directives" style={{ color: '#66ccff', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '4px' }}>
+ <span className="info-label">Edit:</span>
<span className="info-value">Directives</span>
</Link>
</div>
)}
- <div className="status-item">
- <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>
</div>
</div>
@@ -209,28 +209,18 @@ export function VNInterface({ onLogout }: VNInterfaceProps) {
</div>
</div>
<div className="settings-section">
- <h3>Experimental</h3>
+ <h3>Feature Flags</h3>
<div className="setting-item">
- <label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
+ <label>
<input
type="checkbox"
- checked={documentUiEnabled}
- onChange={(e) => setDocumentUiEnabled(e.target.checked)}
- />
- Document UI (Experimental)
+ checked={documentEditorEnabled}
+ onChange={(e) => setDocumentEditorEnabled(e.target.checked)}
+ /> Document Editor (Directives)
</label>
- <p style={{ fontSize: '0.8em', color: '#9ca3af', marginTop: '4px' }}>
- Replace the directive management interface with an interactive document editor. This is a proof of concept.
- </p>
- {documentUiEnabled && (
- <Link
- to="/directives"
- style={{ display: 'inline-block', marginTop: '8px', color: '#ff66cc', fontSize: '0.9em' }}
- onClick={() => showSettingsModalStore.set(false)}
- >
- Open Directives Editor {'\u2192'}
- </Link>
- )}
+ <div style={{ fontSize: '0.8em', opacity: 0.7, marginTop: '4px' }}>
+ Enable the directive document editor interface
+ </div>
</div>
</div>
<div className="settings-section">
diff --git a/frontend/src/components/document/AutoSavePlugin.tsx b/frontend/src/components/document/AutoSavePlugin.tsx
deleted file mode 100644
index d3d0eb5..0000000
--- a/frontend/src/components/document/AutoSavePlugin.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import { useEffect, useRef, useState, useCallback } from 'react';
-import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
-import { UNDO_COMMAND } from 'lexical';
-
-const COUNTDOWN_DURATION_MS = 3000;
-const TICK_INTERVAL_MS = 50;
-
-interface AutoSavePluginProps {
- onAutoSave: (content: string) => void;
- getContent: () => string;
- enabled?: boolean;
-}
-
-export default function AutoSavePlugin({
- onAutoSave,
- getContent,
- enabled = true,
-}: AutoSavePluginProps) {
- const [editor] = useLexicalComposerContext();
- const [countdown, setCountdown] = useState<number | null>(null);
- const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
- const startTimeRef = useRef<number>(0);
- const pendingContentRef = useRef<string>('');
- const lastSavedContentRef = useRef<string>('');
-
- const clearTimer = useCallback(() => {
- if (timerRef.current !== null) {
- clearInterval(timerRef.current);
- timerRef.current = null;
- }
- setCountdown(null);
- }, []);
-
- const cancelCountdown = useCallback(() => {
- clearTimer();
- }, [clearTimer]);
-
- const startCountdown = useCallback(
- (content: string) => {
- pendingContentRef.current = content;
- clearTimer();
-
- startTimeRef.current = Date.now();
- setCountdown(COUNTDOWN_DURATION_MS);
-
- timerRef.current = setInterval(() => {
- const elapsed = Date.now() - startTimeRef.current;
- const remaining = COUNTDOWN_DURATION_MS - elapsed;
-
- if (remaining <= 0) {
- clearTimer();
- lastSavedContentRef.current = pendingContentRef.current;
- onAutoSave(pendingContentRef.current);
- } else {
- setCountdown(remaining);
- }
- }, TICK_INTERVAL_MS);
- },
- [clearTimer, onAutoSave]
- );
-
- // Listen for editor updates (content changes)
- useEffect(() => {
- if (!enabled) return;
-
- const unregister = editor.registerUpdateListener(({ editorState, dirtyElements, dirtyLeaves }) => {
- // Only trigger on actual content changes
- if (dirtyElements.size === 0 && dirtyLeaves.size === 0) return;
-
- const content = getContent();
- if (content !== lastSavedContentRef.current) {
- startCountdown(content);
- }
- });
-
- return unregister;
- }, [editor, enabled, getContent, startCountdown]);
-
- // Listen for undo command to cancel countdown
- useEffect(() => {
- const unregister = editor.registerCommand(
- UNDO_COMMAND,
- () => {
- cancelCountdown();
- return false; // Don't prevent the undo from executing
- },
- 1 // COMMAND_PRIORITY_LOW
- );
-
- return unregister;
- }, [editor, cancelCountdown]);
-
- // Listen for Escape key to cancel countdown
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Escape' && countdown !== null) {
- e.preventDefault();
- cancelCountdown();
- }
- };
-
- document.addEventListener('keydown', handleKeyDown);
- return () => document.removeEventListener('keydown', handleKeyDown);
- }, [countdown, cancelCountdown]);
-
- // Cleanup on unmount
- useEffect(() => {
- return () => {
- if (timerRef.current !== null) {
- clearInterval(timerRef.current);
- }
- };
- }, []);
-
- if (countdown === null) return null;
-
- const progressPercent = (countdown / COUNTDOWN_DURATION_MS) * 100;
- const secondsLeft = Math.ceil(countdown / 1000);
-
- return (
- <div className="autosave-bar">
- <span className="autosave-bar-text">
- Saving in {secondsLeft}s... <kbd>Esc</kbd> to cancel
- </span>
- <div className="autosave-bar-progress-track">
- <div
- className="autosave-bar-progress-fill"
- style={{ width: `${progressPercent}%` }}
- />
- </div>
- <button
- className="autosave-bar-cancel"
- onClick={cancelCountdown}
- type="button"
- >
- Cancel
- </button>
- </div>
- );
-}
diff --git a/frontend/src/components/document/ContextMenu.css b/frontend/src/components/document/ContextMenu.css
deleted file mode 100644
index 4eed119..0000000
--- a/frontend/src/components/document/ContextMenu.css
+++ /dev/null
@@ -1,79 +0,0 @@
-/* ============================================
- Custom Context Menu
- ============================================ */
-
-.ctx-menu {
- position: fixed;
- z-index: 10000;
- min-width: 200px;
- background: #ffffff;
- border: 1px solid #e5e7eb;
- border-radius: 10px;
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.06);
- padding: 4px 0;
- animation: ctxFadeIn 0.12s ease-out;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-}
-
-/* Menu item */
-.ctx-menu-item {
- display: flex;
- align-items: center;
- gap: 0.6rem;
- width: 100%;
- padding: 0.5rem 0.85rem;
- border: none;
- background: none;
- cursor: pointer;
- font-size: 0.875rem;
- color: #1f2937;
- text-align: left;
- border-radius: 0;
- transition: background 0.1s ease;
-}
-
-.ctx-menu-item:hover:not(:disabled) {
- background: #f3f4f6;
-}
-
-.ctx-menu-item:active:not(:disabled) {
- background: #e5e7eb;
-}
-
-/* Disabled state */
-.ctx-menu-item-disabled,
-.ctx-menu-item:disabled {
- color: #9ca3af;
- cursor: not-allowed;
-}
-
-/* Icon */
-.ctx-menu-icon {
- font-size: 1rem;
- width: 1.25rem;
- text-align: center;
- flex-shrink: 0;
-}
-
-.ctx-menu-label {
- flex: 1;
-}
-
-/* Divider */
-.ctx-menu-divider {
- height: 1px;
- background: #e5e7eb;
- margin: 4px 0;
-}
-
-/* Animation */
-@keyframes ctxFadeIn {
- from {
- opacity: 0;
- transform: scale(0.96);
- }
- to {
- opacity: 1;
- transform: scale(1);
- }
-}
diff --git a/frontend/src/components/document/ContextMenu.tsx b/frontend/src/components/document/ContextMenu.tsx
deleted file mode 100644
index 5aed940..0000000
--- a/frontend/src/components/document/ContextMenu.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import { useCallback, useEffect, useRef } from 'react';
-import './ContextMenu.css';
-
-export interface ContextMenuAction {
- label: string;
- icon: string;
- disabled?: boolean;
- onClick: () => void;
-}
-
-export interface ContextMenuProps {
- x: number;
- y: number;
- actions: ContextMenuAction[];
- dividerAfter?: number[];
- onClose: () => void;
-}
-
-export default function ContextMenu({
- x,
- y,
- actions,
- dividerAfter = [],
- onClose,
-}: ContextMenuProps) {
- const menuRef = useRef<HTMLDivElement>(null);
-
- // Adjust position so menu stays within viewport
- const adjustedPosition = useCallback(() => {
- const el = menuRef.current;
- if (!el) return { left: x, top: y };
- const rect = el.getBoundingClientRect();
- const left = x + rect.width > window.innerWidth ? x - rect.width : x;
- const top = y + rect.height > window.innerHeight ? y - rect.height : y;
- return { left: Math.max(0, left), top: Math.max(0, top) };
- }, [x, y]);
-
- // Close on click outside
- useEffect(() => {
- const handler = (e: MouseEvent) => {
- if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
- onClose();
- }
- };
- // Use capture so we catch clicks before any other handler
- document.addEventListener('mousedown', handler, true);
- return () => document.removeEventListener('mousedown', handler, true);
- }, [onClose]);
-
- // Close on Escape
- useEffect(() => {
- const handler = (e: KeyboardEvent) => {
- if (e.key === 'Escape') onClose();
- };
- document.addEventListener('keydown', handler);
- return () => document.removeEventListener('keydown', handler);
- }, [onClose]);
-
- // After mount, adjust position
- useEffect(() => {
- const el = menuRef.current;
- if (!el) return;
- const pos = adjustedPosition();
- el.style.left = `${pos.left}px`;
- el.style.top = `${pos.top}px`;
- }, [adjustedPosition]);
-
- const dividerSet = new Set(dividerAfter);
-
- return (
- <div
- ref={menuRef}
- className="ctx-menu"
- style={{ left: x, top: y }}
- role="menu"
- >
- {actions.map((action, i) => (
- <div key={i}>
- <button
- className={`ctx-menu-item ${action.disabled ? 'ctx-menu-item-disabled' : ''}`}
- role="menuitem"
- disabled={action.disabled}
- onClick={() => {
- if (!action.disabled) {
- action.onClick();
- onClose();
- }
- }}
- >
- <span className="ctx-menu-icon">{action.icon}</span>
- <span className="ctx-menu-label">{action.label}</span>
- </button>
- {dividerSet.has(i) && <div className="ctx-menu-divider" />}
- </div>
- ))}
- </div>
- );
-}
diff --git a/frontend/src/components/document/DirectiveFileTree.tsx b/frontend/src/components/document/DirectiveFileTree.tsx
deleted file mode 100644
index bacffe6..0000000
--- a/frontend/src/components/document/DirectiveFileTree.tsx
+++ /dev/null
@@ -1,166 +0,0 @@
-import React, { useEffect, useState } from 'react'
-import { listDirectives, DirectiveSummary } from '../../services/directiveApi'
-
-interface DirectiveFileTreeProps {
- selectedDirectiveId: string | null
- onSelectDirective: (id: string) => void
- onNewDirective: () => void
-}
-
-interface GroupState {
- [key: string]: boolean
-}
-
-const STATUS_GROUPS = [
- { key: 'active', label: 'Active', defaultExpanded: true },
- { key: 'idle', label: 'Idle', defaultExpanded: true },
- { key: 'draft', label: 'Draft', defaultExpanded: false },
- { key: 'archived', label: 'Archived', defaultExpanded: false },
-] as const
-
-function statusColor(status: string): string {
- switch (status.toLowerCase()) {
- case 'active':
- case 'running':
- return '#4caf50'
- case 'idle':
- case 'paused':
- return '#ffc107'
- case 'draft':
- case 'pending':
- return '#9e9e9e'
- case 'archived':
- case 'failed':
- return '#f44336'
- default:
- return '#9e9e9e'
- }
-}
-
-function groupDirectives(directives: DirectiveSummary[]): Record<string, DirectiveSummary[]> {
- const groups: Record<string, DirectiveSummary[]> = {
- active: [],
- idle: [],
- draft: [],
- archived: [],
- }
-
- for (const d of directives) {
- const s = d.status.toLowerCase()
- if (s === 'active' || s === 'running') {
- groups.active.push(d)
- } else if (s === 'idle' || s === 'paused') {
- groups.idle.push(d)
- } else if (s === 'draft' || s === 'pending') {
- groups.draft.push(d)
- } else {
- groups.archived.push(d)
- }
- }
-
- return groups
-}
-
-export function DirectiveFileTree({ selectedDirectiveId, onSelectDirective, onNewDirective }: DirectiveFileTreeProps) {
- const [directives, setDirectives] = useState<DirectiveSummary[]>([])
- const [loading, setLoading] = useState(true)
- const [error, setError] = useState<string | null>(null)
- const [expanded, setExpanded] = useState<GroupState>(() => {
- const state: GroupState = {}
- for (const g of STATUS_GROUPS) {
- state[g.key] = g.defaultExpanded
- }
- return state
- })
-
- useEffect(() => {
- async function load() {
- try {
- setLoading(true)
- const data = await listDirectives()
- setDirectives(data)
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to load directives')
- } finally {
- setLoading(false)
- }
- }
- load()
- }, [])
-
- const toggleGroup = (key: string) => {
- setExpanded(prev => ({ ...prev, [key]: !prev[key] }))
- }
-
- const grouped = groupDirectives(directives)
-
- return (
- <div className="directive-file-tree">
- <div className="file-tree-header">
- <span className="file-tree-title">Directives</span>
- <button className="file-tree-new-btn" onClick={onNewDirective} title="New Directive">
- +
- </button>
- </div>
-
- {loading && <div className="file-tree-loading">Loading...</div>}
- {error && <div className="file-tree-error">{error}</div>}
-
- {!loading && !error && (
- <div className="file-tree-groups">
- {STATUS_GROUPS.map(group => {
- const items = grouped[group.key]
- if (!items || items.length === 0) return null
-
- return (
- <div key={group.key} className="file-tree-group">
- <button
- className="file-tree-group-header"
- onClick={() => toggleGroup(group.key)}
- >
- <span className={`file-tree-chevron ${expanded[group.key] ? 'expanded' : ''}`}>
- {'\u25B6'}
- </span>
- <span className="file-tree-group-label">{group.label}</span>
- <span className="file-tree-group-count">{items.length}</span>
- </button>
-
- {expanded[group.key] && (
- <div className="file-tree-items">
- {items.map(directive => (
- <button
- key={directive.id}
- className={`file-tree-item ${selectedDirectiveId === directive.id ? 'selected' : ''}`}
- onClick={() => onSelectDirective(directive.id)}
- title={directive.title}
- >
- <span
- className="file-tree-status-dot"
- style={{ backgroundColor: statusColor(directive.status) }}
- />
- <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>
- )}
- </div>
- )
- })}
- </div>
- )}
- </div>
- )
-}
diff --git a/frontend/src/components/document/DocumentEditor.css b/frontend/src/components/document/DocumentEditor.css
deleted file mode 100644
index 0be1151..0000000
--- a/frontend/src/components/document/DocumentEditor.css
+++ /dev/null
@@ -1,246 +0,0 @@
-/* ============================================
- Document Editor - Clean, modern document UI
- ============================================ */
-
-.document-editor-container {
- max-width: 800px;
- margin: 0 auto;
- padding: 2rem 1rem;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
- position: relative;
-}
-
-/* ---- Lexical Root ---- */
-.doc-editor-root {
- outline: none;
- min-height: 400px;
- padding: 1rem 0;
- color: #1a1a2e;
- line-height: 1.7;
- font-size: 16px;
-}
-
-/* ---- Headings ---- */
-.doc-editor-h1 {
- font-size: 2.25rem;
- font-weight: 700;
- color: #0f0f23;
- margin: 0 0 0.25rem 0;
- padding: 0;
- line-height: 1.3;
- letter-spacing: -0.02em;
- border: none;
-}
-
-.doc-editor-h2 {
- font-size: 1.5rem;
- font-weight: 600;
- color: #1a1a2e;
- margin: 1.5rem 0 0.5rem 0;
- line-height: 1.4;
-}
-
-.doc-editor-h3 {
- font-size: 1.2rem;
- font-weight: 600;
- color: #2a2a4a;
- margin: 1.25rem 0 0.4rem 0;
- line-height: 1.4;
-}
-
-/* ---- Paragraphs ---- */
-.doc-editor-paragraph {
- margin: 0.4rem 0;
- padding: 0;
- color: #374151;
- line-height: 1.7;
-}
-
-/* ---- Text Formatting ---- */
-.doc-editor-text-bold {
- font-weight: 700;
-}
-
-.doc-editor-text-italic {
- font-style: italic;
-}
-
-.doc-editor-text-underline {
- text-decoration: underline;
-}
-
-.doc-editor-text-strikethrough {
- text-decoration: line-through;
-}
-
-.doc-editor-text-code {
- background: #f3f4f6;
- border-radius: 3px;
- padding: 0.15em 0.35em;
- font-family: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, Consolas, monospace;
- font-size: 0.9em;
- color: #e11d48;
-}
-
-/* ---- Lists ---- */
-.doc-editor-list-ul {
- padding-left: 1.5rem;
- margin: 0.5rem 0;
- list-style-type: disc;
-}
-
-.doc-editor-list-ol {
- padding-left: 1.5rem;
- margin: 0.5rem 0;
- list-style-type: decimal;
-}
-
-.doc-editor-listitem {
- margin: 0.25rem 0;
- color: #374151;
-}
-
-.doc-editor-nested-listitem {
- list-style-type: circle;
-}
-
-/* ---- Links ---- */
-.doc-editor-link {
- color: #2563eb;
- text-decoration: underline;
- cursor: pointer;
-}
-
-.doc-editor-link:hover {
- color: #1d4ed8;
-}
-
-/* ---- Placeholder ---- */
-.doc-editor-placeholder {
- color: #9ca3af;
- position: absolute;
- top: 1rem;
- left: 0;
- pointer-events: none;
- font-size: 16px;
- user-select: none;
-}
-
-/* ---- Content Editable wrapper ---- */
-.doc-editor-input {
- position: relative;
-}
-
-.doc-editor-content-editable {
- outline: none;
- position: relative;
-}
-
-/* ---- Divider between title and body ---- */
-.doc-editor-title-divider {
- height: 1px;
- background: #e5e7eb;
- margin: 0.5rem 0 1rem 0;
- border: none;
-}
-
-/* ============================================
- Auto-Save Countdown Bar
- ============================================ */
-
-.autosave-bar {
- position: sticky;
- bottom: 0;
- left: 0;
- right: 0;
- z-index: 50;
- background: #fefce8;
- border-top: 1px solid #fde68a;
- padding: 0.5rem 1rem;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 1rem;
- font-size: 0.85rem;
- color: #92400e;
- transition: opacity 0.2s ease;
-}
-
-.autosave-bar-hidden {
- opacity: 0;
- pointer-events: none;
- height: 0;
- padding: 0;
- overflow: hidden;
-}
-
-.autosave-bar-text {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- white-space: nowrap;
-}
-
-.autosave-bar-text kbd {
- background: #fef3c7;
- border: 1px solid #fde68a;
- border-radius: 3px;
- padding: 0.1em 0.4em;
- font-size: 0.8em;
- font-family: inherit;
-}
-
-.autosave-bar-progress-track {
- flex: 1;
- height: 4px;
- background: #fde68a;
- border-radius: 2px;
- overflow: hidden;
- min-width: 80px;
-}
-
-.autosave-bar-progress-fill {
- height: 100%;
- background: #f59e0b;
- border-radius: 2px;
- transition: width 0.1s linear;
-}
-
-.autosave-bar-cancel {
- background: none;
- border: 1px solid #d97706;
- border-radius: 4px;
- color: #92400e;
- padding: 0.2rem 0.6rem;
- cursor: pointer;
- font-size: 0.8rem;
- white-space: nowrap;
- transition: background 0.15s ease;
-}
-
-.autosave-bar-cancel:hover {
- background: #fef3c7;
-}
-
-/* ============================================
- Responsive
- ============================================ */
-
-@media (max-width: 640px) {
- .document-editor-container {
- padding: 1rem 0.75rem;
- }
-
- .doc-editor-h1 {
- font-size: 1.75rem;
- }
-
- .doc-editor-root {
- font-size: 15px;
- }
-
- .autosave-bar {
- font-size: 0.78rem;
- padding: 0.4rem 0.75rem;
- }
-}
diff --git a/frontend/src/components/document/DocumentEditor.tsx b/frontend/src/components/document/DocumentEditor.tsx
deleted file mode 100644
index 2ef37fe..0000000
--- a/frontend/src/components/document/DocumentEditor.tsx
+++ /dev/null
@@ -1,236 +0,0 @@
-import { useCallback, useRef, useState } from 'react';
-import { LexicalComposer } from '@lexical/react/LexicalComposer';
-import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
-import { ContentEditable } from '@lexical/react/LexicalContentEditable';
-import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
-import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
-import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
-import { HeadingNode } from '@lexical/rich-text';
-import { ListNode, ListItemNode } from '@lexical/list';
-import { LinkNode } from '@lexical/link';
-import {
- $getRoot,
- $createParagraphNode,
- $createTextNode,
- type EditorState,
- type LexicalEditor,
-} from 'lexical';
-import { $createHeadingNode } from '@lexical/rich-text';
-
-import { StepsDiagramNode, $isStepsDiagramNode, $createStepsDiagramNode } from './nodes/StepsDiagramNode';
-import { ContractBlockNode, $isContractBlockNode } from './nodes/ContractBlockNode';
-import editorTheme from './EditorTheme';
-import AutoSavePlugin from './AutoSavePlugin';
-import ContextMenu, { type ContextMenuAction } from './ContextMenu';
-import './DocumentEditor.css';
-import './nodes/StepsDiagram.css';
-import './nodes/ContractBlock.css';
-
-interface DocumentEditorProps {
- directiveId: string;
- title: string;
- goal: string;
- status: string;
- prBranch?: string | null;
- onGoalChange?: (newGoal: string) => void;
- onTitleChange?: (newTitle: string) => void;
- onCleanup?: () => void;
- onCreatePr?: () => void;
- onPlanOrders?: () => void;
- onTogglePause?: () => void;
- readOnly?: boolean;
-}
-
-function buildInitialEditorState(directiveId: string, title: string, goal: string) {
- return () => {
- const root = $getRoot();
-
- // Title as H1
- const heading = $createHeadingNode('h1');
- heading.append($createTextNode(title));
- root.append(heading);
-
- // Goal as paragraph(s), split by newlines
- const lines = goal.split('\n');
- for (const line of lines) {
- const paragraph = $createParagraphNode();
- if (line.trim()) {
- paragraph.append($createTextNode(line));
- }
- root.append(paragraph);
- }
-
- // Insert steps diagram node after the goal content
- const stepsNode = $createStepsDiagramNode(directiveId);
- root.append(stepsNode);
-
- // Add a trailing paragraph so the user can type below the diagram
- const trailingParagraph = $createParagraphNode();
- root.append(trailingParagraph);
- };
-}
-
-function onError(error: Error) {
- console.error('[DocumentEditor] Lexical error:', error);
-}
-
-export default function DocumentEditor({
- directiveId,
- title,
- goal,
- status,
- prBranch,
- onGoalChange,
- onTitleChange,
- onCleanup,
- onCreatePr,
- onPlanOrders,
- onTogglePause,
- readOnly = false,
-}: DocumentEditorProps) {
- const editorRef = useRef<LexicalEditor | null>(null);
- const latestGoalRef = useRef(goal);
- const latestTitleRef = useRef(title);
-
- // Context menu state
- const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
-
- const initialConfig = {
- namespace: `DocumentEditor-${directiveId}`,
- theme: editorTheme,
- editorState: buildInitialEditorState(directiveId, title, goal),
- nodes: [HeadingNode, ListNode, ListItemNode, LinkNode, StepsDiagramNode, ContractBlockNode],
- onError,
- editable: !readOnly,
- };
-
- const handleChange = useCallback(
- (_editorState: EditorState, editor: LexicalEditor) => {
- editorRef.current = editor;
-
- editor.getEditorState().read(() => {
- const root = $getRoot();
- const children = root.getChildren();
-
- let newTitle = '';
- const goalLines: string[] = [];
-
- for (let i = 0; i < children.length; i++) {
- const child = children[i];
- // Skip decorator nodes (steps diagram, contract blocks) when extracting text
- if ($isStepsDiagramNode(child)) continue;
- if ($isContractBlockNode(child)) continue;
-
- const text = child.getTextContent();
-
- if (i === 0 && child.getType() === 'heading') {
- newTitle = text;
- } else {
- goalLines.push(text);
- }
- }
-
- const newGoal = goalLines.join('\n');
-
- if (newTitle !== latestTitleRef.current) {
- latestTitleRef.current = newTitle;
- onTitleChange?.(newTitle);
- }
-
- if (newGoal !== latestGoalRef.current) {
- latestGoalRef.current = newGoal;
- onGoalChange?.(newGoal);
- }
- });
- },
- [onGoalChange, onTitleChange]
- );
-
- const getContent = useCallback(() => {
- return latestGoalRef.current;
- }, []);
-
- const handleAutoSave = useCallback(
- (content: string) => {
- onGoalChange?.(content);
- },
- [onGoalChange]
- );
-
- // Context menu handler
- const handleContextMenu = useCallback(
- (e: React.MouseEvent) => {
- e.preventDefault();
- setCtxMenu({ x: e.clientX, y: e.clientY });
- },
- []
- );
-
- const closeCtxMenu = useCallback(() => setCtxMenu(null), []);
-
- const isPaused = status === 'paused';
- const isIdle = status === 'idle';
-
- const ctxActions: ContextMenuAction[] = [
- {
- label: 'Clean Up',
- icon: '\uD83E\uDDF9', // broom
- disabled: !isIdle,
- onClick: () => onCleanup?.(),
- },
- {
- label: 'Update PR',
- icon: '\uD83D\uDD00', // shuffle
- disabled: !prBranch,
- onClick: () => onCreatePr?.(),
- },
- {
- label: 'Plan Orders',
- icon: '\uD83D\uDCCB', // clipboard
- onClick: () => onPlanOrders?.(),
- },
- {
- label: isPaused ? 'Resume Directive' : 'Pause Directive',
- icon: isPaused ? '\u25B6' : '\u23F8',
- onClick: () => onTogglePause?.(),
- },
- ];
-
- return (
- <div className="document-editor-container" onContextMenu={handleContextMenu}>
- <LexicalComposer initialConfig={initialConfig}>
- <div className="doc-editor-input">
- <RichTextPlugin
- contentEditable={
- <ContentEditable className="doc-editor-content-editable doc-editor-root" />
- }
- placeholder={
- <div className="doc-editor-placeholder">Start writing...</div>
- }
- ErrorBoundary={LexicalErrorBoundary}
- />
- </div>
- <HistoryPlugin />
- <OnChangePlugin onChange={handleChange} />
- {!readOnly && (
- <AutoSavePlugin
- onAutoSave={handleAutoSave}
- getContent={getContent}
- enabled={!readOnly}
- />
- )}
- </LexicalComposer>
-
- {ctxMenu && (
- <ContextMenu
- x={ctxMenu.x}
- y={ctxMenu.y}
- actions={ctxActions}
- dividerAfter={[2]}
- onClose={closeCtxMenu}
- />
- )}
- </div>
- );
-}
-
diff --git a/frontend/src/components/document/DocumentLayout.css b/frontend/src/components/document/DocumentLayout.css
deleted file mode 100644
index ae73e7a..0000000
--- a/frontend/src/components/document/DocumentLayout.css
+++ /dev/null
@@ -1,363 +0,0 @@
-/* Document Layout - Main container */
-.document-layout {
- display: flex;
- height: 100vh;
- width: 100vw;
- overflow: hidden;
- background: #1a1a2e;
- color: #e0e0e0;
-}
-
-/* Sidebar */
-.document-sidebar {
- flex-shrink: 0;
- height: 100%;
- overflow-y: auto;
- overflow-x: hidden;
- background: #16162a;
- border-right: 1px solid #2a2a4a;
-}
-
-/* Back link */
-.document-sidebar-back {
- padding: 8px 12px;
- border-bottom: 1px solid #2a2a4a;
-}
-
-.document-back-link {
- color: #9ca3af;
- text-decoration: none;
- font-size: 0.85rem;
- display: flex;
- align-items: center;
- gap: 4px;
- transition: color 0.15s;
-}
-
-.document-back-link:hover {
- color: #e0e0e0;
-}
-
-/* Resize handle */
-.document-resize-handle {
- width: 4px;
- cursor: col-resize;
- background: transparent;
- flex-shrink: 0;
- transition: background 0.15s;
-}
-
-.document-resize-handle:hover,
-.document-resize-handle:active {
- background: #4a4a8a;
-}
-
-/* Main content area */
-.document-main {
- flex: 1;
- display: flex;
- flex-direction: column;
- min-width: 0;
- overflow: hidden;
-}
-
-/* Top bar */
-.document-topbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 8px 20px;
- border-bottom: 1px solid #2a2a4a;
- background: #1e1e38;
- flex-shrink: 0;
-}
-
-.document-topbar-left {
- display: flex;
- align-items: center;
- gap: 12px;
- min-width: 0;
-}
-
-.document-topbar-title {
- font-size: 16px;
- font-weight: 600;
- margin: 0;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- color: #f0f0f0;
-}
-
-.document-topbar-right {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.document-topbar-gear {
- background: none;
- border: none;
- color: #888;
- font-size: 20px;
- cursor: pointer;
- padding: 4px 8px;
- border-radius: 4px;
- transition: color 0.15s, background 0.15s;
-}
-
-.document-topbar-gear:hover {
- color: #fff;
- background: rgba(255, 255, 255, 0.08);
-}
-
-/* Status badge */
-.doc-status-badge {
- display: inline-block;
- padding: 2px 10px;
- border-radius: 10px;
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- color: #fff;
- letter-spacing: 0.5px;
-}
-
-/* Content area */
-.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 */
-.document-placeholder {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- height: 100%;
- text-align: center;
- color: #888;
- padding: 40px;
-}
-
-.document-placeholder-icon {
- font-size: 48px;
- margin-bottom: 16px;
- opacity: 0.5;
-}
-
-.document-placeholder h2 {
- font-size: 20px;
- font-weight: 500;
- margin: 0 0 8px;
- color: #aaa;
-}
-
-.document-placeholder p {
- font-size: 14px;
- margin: 0;
- max-width: 400px;
- line-height: 1.5;
-}
-
-.document-error {
- color: #f44336;
-}
-
-/* File Tree styles */
-.directive-file-tree {
- display: flex;
- flex-direction: column;
- height: 100%;
- font-size: 13px;
-}
-
-.file-tree-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 12px 14px;
- border-bottom: 1px solid #2a2a4a;
- flex-shrink: 0;
-}
-
-.file-tree-title {
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 1px;
- color: #888;
-}
-
-.file-tree-new-btn {
- background: none;
- border: 1px solid #3a3a6a;
- color: #aaa;
- font-size: 16px;
- width: 24px;
- height: 24px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 4px;
- cursor: pointer;
- padding: 0;
- line-height: 1;
- transition: all 0.15s;
-}
-
-.file-tree-new-btn:hover {
- background: #3a3a6a;
- color: #fff;
- border-color: #5a5a9a;
-}
-
-.file-tree-loading,
-.file-tree-error {
- padding: 16px;
- color: #888;
- font-size: 12px;
- text-align: center;
-}
-
-.file-tree-error {
- color: #f44336;
-}
-
-.file-tree-groups {
- flex: 1;
- overflow-y: auto;
- padding: 4px 0;
-}
-
-/* Group header */
-.file-tree-group {
- margin-bottom: 2px;
-}
-
-.file-tree-group-header {
- display: flex;
- align-items: center;
- gap: 6px;
- width: 100%;
- padding: 6px 14px;
- background: none;
- border: none;
- color: #999;
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- cursor: pointer;
- text-align: left;
- transition: color 0.15s;
-}
-
-.file-tree-group-header:hover {
- color: #ccc;
-}
-
-.file-tree-chevron {
- font-size: 8px;
- transition: transform 0.15s;
- display: inline-block;
-}
-
-.file-tree-chevron.expanded {
- transform: rotate(90deg);
-}
-
-.file-tree-group-count {
- margin-left: auto;
- color: #666;
- font-size: 10px;
-}
-
-.file-tree-group-label {
- flex: 1;
-}
-
-/* Tree items */
-.file-tree-items {
- padding: 0;
-}
-
-.file-tree-item {
- display: flex;
- align-items: center;
- gap: 8px;
- width: 100%;
- padding: 5px 14px 5px 28px;
- background: none;
- border: none;
- color: #ccc;
- font-size: 13px;
- cursor: pointer;
- text-align: left;
- transition: background 0.1s;
- white-space: nowrap;
- overflow: hidden;
-}
-
-.file-tree-item:hover {
- background: rgba(255, 255, 255, 0.05);
-}
-
-.file-tree-item.selected {
- background: rgba(100, 100, 200, 0.15);
- color: #fff;
-}
-
-.file-tree-status-dot {
- width: 7px;
- height: 7px;
- border-radius: 50%;
- flex-shrink: 0;
-}
-
-.file-tree-doc-icon {
- font-size: 14px;
- flex-shrink: 0;
- opacity: 0.7;
-}
-
-.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 */
-@media (max-width: 768px) {
- .document-sidebar {
- position: absolute;
- z-index: 100;
- left: 0;
- top: 0;
- height: 100%;
- box-shadow: 4px 0 20px rgba(0, 0, 0, 0.5);
- }
-
- .document-resize-handle {
- display: none;
- }
-}
diff --git a/frontend/src/components/document/DocumentLayout.tsx b/frontend/src/components/document/DocumentLayout.tsx
deleted file mode 100644
index 05f4190..0000000
--- a/frontend/src/components/document/DocumentLayout.tsx
+++ /dev/null
@@ -1,316 +0,0 @@
-import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
-import { useParams, useNavigate, Link } from 'react-router-dom'
-import { DirectiveFileTree } from './DirectiveFileTree'
-import DocumentEditor from './DocumentEditor'
-import { ToastProvider, useToast } from './Toast'
-import {
- type DirectiveWithSteps,
- type DirectiveStep,
- getDirective,
- getDirectiveSteps,
- updateGoal,
- updateDirective,
- cleanupDirective,
- createPr,
- pickUpOrders,
- pauseDirective,
- startDirective,
-} from '../../services/directiveApi'
-import './DocumentLayout.css'
-
-function StatusBadge({ status }: { status: string }) {
- const colors: Record<string, string> = {
- active: '#4caf50',
- running: '#4caf50',
- idle: '#ffc107',
- paused: '#ffc107',
- draft: '#9e9e9e',
- pending: '#9e9e9e',
- archived: '#f44336',
- failed: '#f44336',
- }
- const color = colors[status.toLowerCase()] || '#9e9e9e'
-
- return (
- <span className="doc-status-badge" style={{ backgroundColor: color }}>
- {status}
- </span>
- )
-}
-
-function DocumentLayoutInner() {
- const { id: urlDirectiveId } = useParams<{ id: string }>()
- const navigate = useNavigate()
- const { addToast } = useToast()
-
- const [selectedId, setSelectedId] = useState<string | null>(urlDirectiveId || null)
- const [directive, setDirective] = useState<DirectiveWithSteps | null>(null)
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState<string | null>(null)
- const [sidebarWidth, setSidebarWidth] = useState(250)
- const resizingRef = useRef(false)
- const startXRef = useRef(0)
- const startWidthRef = useRef(250)
- const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
-
- // Sync URL param on mount
- useEffect(() => {
- if (urlDirectiveId && urlDirectiveId !== selectedId) {
- setSelectedId(urlDirectiveId)
- }
- }, [urlDirectiveId])
-
- // Handle directive selection - update URL
- const handleSelectDirective = useCallback((id: string) => {
- setSelectedId(id)
- navigate(`/directives/${id}`, { replace: true })
- }, [navigate])
-
- // Load directive when selected
- useEffect(() => {
- if (!selectedId) {
- setDirective(null)
- return
- }
-
- let cancelled = false
- async function load() {
- try {
- setLoading(true)
- setError(null)
- const data = await getDirective(selectedId!)
- if (!cancelled) setDirective(data)
- } catch (err) {
- if (!cancelled) {
- const msg = err instanceof Error ? err.message : 'Failed to load directive'
- setError(msg)
- addToast(msg, 'error')
- }
- } finally {
- if (!cancelled) setLoading(false)
- }
- }
- load()
-
- return () => { cancelled = true }
- }, [selectedId, addToast])
-
- // Step polling (after goal update triggers supervisor)
- const startStepPolling = useCallback(() => {
- if (pollRef.current) clearInterval(pollRef.current)
- pollRef.current = setInterval(async () => {
- if (!selectedId) return
- try {
- const data = await getDirective(selectedId)
- setDirective(data)
- } catch {
- // Silently fail for polling
- }
- }, 3000)
- // Stop after 60 seconds
- setTimeout(() => {
- if (pollRef.current) {
- clearInterval(pollRef.current)
- pollRef.current = null
- }
- }, 60000)
- }, [selectedId])
-
- useEffect(() => {
- return () => {
- if (pollRef.current) clearInterval(pollRef.current)
- }
- }, [])
-
- // Auto-save goal changes
- const handleGoalChange = useCallback(async (newGoal: string) => {
- if (!selectedId) return
- try {
- const updated = await updateGoal(selectedId, newGoal)
- setDirective(updated)
- addToast('Goal saved', 'success')
- startStepPolling()
- } catch (err) {
- addToast(`Failed to save goal: ${(err as Error).message}`, 'error')
- }
- }, [selectedId, addToast, startStepPolling])
-
- const handleTitleChange = useCallback(async (newTitle: string) => {
- if (!selectedId || !directive) return
- try {
- const updated = await updateDirective(selectedId, {
- title: newTitle,
- version: directive.version,
- })
- setDirective(updated)
- } catch (err) {
- addToast(`Failed to update title: ${(err as Error).message}`, 'error')
- }
- }, [selectedId, directive, addToast])
-
- const handleCleanup = useCallback(async () => {
- if (!selectedId) return
- try {
- await cleanupDirective(selectedId)
- addToast('Cleanup contract spawned', 'success')
- startStepPolling()
- } catch (err) {
- addToast(`Cleanup failed: ${(err as Error).message}`, 'error')
- }
- }, [selectedId, addToast, startStepPolling])
-
- const handleCreatePr = useCallback(async () => {
- if (!selectedId) return
- try {
- await createPr(selectedId)
- addToast('PR update triggered', 'success')
- } catch (err) {
- addToast(`PR update failed: ${(err as Error).message}`, 'error')
- }
- }, [selectedId, addToast])
-
- const handlePlanOrders = useCallback(async () => {
- if (!selectedId) return
- try {
- await pickUpOrders(selectedId)
- addToast('Planning orders...', 'info')
- startStepPolling()
- } catch (err) {
- addToast(`Plan orders failed: ${(err as Error).message}`, 'error')
- }
- }, [selectedId, addToast, startStepPolling])
-
- const handleTogglePause = useCallback(async () => {
- if (!selectedId || !directive) return
- try {
- if (directive.status === 'paused') {
- const result = await startDirective(selectedId)
- setDirective(result)
- addToast('Directive resumed', 'success')
- } else {
- const updated = await pauseDirective(selectedId)
- setDirective(updated)
- addToast('Directive paused', 'info')
- }
- } catch (err) {
- addToast(`Failed to toggle pause: ${(err as Error).message}`, 'error')
- }
- }, [selectedId, directive, addToast])
-
- // Sidebar resize handlers
- const handleMouseDown = useCallback((e: React.MouseEvent) => {
- resizingRef.current = true
- startXRef.current = e.clientX
- startWidthRef.current = sidebarWidth
- document.body.style.cursor = 'col-resize'
- document.body.style.userSelect = 'none'
-
- const handleMouseMove = (e: MouseEvent) => {
- if (!resizingRef.current) return
- const diff = e.clientX - startXRef.current
- const newWidth = Math.max(180, Math.min(500, startWidthRef.current + diff))
- setSidebarWidth(newWidth)
- }
-
- const handleMouseUp = () => {
- resizingRef.current = false
- document.body.style.cursor = ''
- document.body.style.userSelect = ''
- document.removeEventListener('mousemove', handleMouseMove)
- document.removeEventListener('mouseup', handleMouseUp)
- }
-
- document.addEventListener('mousemove', handleMouseMove)
- document.addEventListener('mouseup', handleMouseUp)
- }, [sidebarWidth])
-
- const handleNewDirective = useCallback(() => {
- // Placeholder - will be implemented with full directive creation flow
- console.log('New directive requested')
- }, [])
-
- return (
- <div className="document-layout">
- {/* Sidebar */}
- <div className="document-sidebar" style={{ width: sidebarWidth }}>
- <div className="document-sidebar-back">
- <Link to="/" className="document-back-link">
- {'\u2190'} Back to Main
- </Link>
- </div>
- <DirectiveFileTree
- selectedDirectiveId={selectedId}
- onSelectDirective={handleSelectDirective}
- onNewDirective={handleNewDirective}
- />
- </div>
-
- {/* Resize handle */}
- <div className="document-resize-handle" onMouseDown={handleMouseDown} />
-
- {/* Main content */}
- <div className="document-main">
- {directive && (
- <div className="document-topbar">
- <div className="document-topbar-left">
- <h1 className="document-topbar-title">{directive.title || 'Untitled'}</h1>
- <StatusBadge status={directive.status} />
- </div>
- <div className="document-topbar-right">
- <button className="document-topbar-gear" title="Settings">
- {'\u2699'}
- </button>
- </div>
- </div>
- )}
-
- <div className="document-content">
- {loading && (
- <div className="document-placeholder">
- <p>Loading directive...</p>
- </div>
- )}
-
- {error && (
- <div className="document-placeholder">
- <p className="document-error">Error: {error}</p>
- </div>
- )}
-
- {!loading && !error && !directive && (
- <div className="document-placeholder">
- <div className="document-placeholder-icon">{'\u{1F4DD}'}</div>
- <h2>No directive selected</h2>
- <p>Select a directive from the sidebar or create a new one to get started.</p>
- </div>
- )}
-
- {!loading && !error && directive && (
- <DocumentEditor
- directiveId={directive.id}
- title={directive.title || 'Untitled'}
- goal={directive.goal || ''}
- status={directive.status}
- prBranch={directive.prBranch || directive.pr_branch}
- onGoalChange={handleGoalChange}
- onTitleChange={handleTitleChange}
- onCleanup={handleCleanup}
- onCreatePr={handleCreatePr}
- onPlanOrders={handlePlanOrders}
- onTogglePause={handleTogglePause}
- />
- )}
- </div>
- </div>
- </div>
- )
-}
-
-// Wrapper that provides toast context
-export default function DocumentLayout() {
- return (
- <ToastProvider>
- <DocumentLayoutInner />
- </ToastProvider>
- )
-}
diff --git a/frontend/src/components/document/DocumentSettings.tsx b/frontend/src/components/document/DocumentSettings.tsx
deleted file mode 100644
index b575b3d..0000000
--- a/frontend/src/components/document/DocumentSettings.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import { useState, useCallback } from 'react'
-import { upsertUserSetting } from '../../services/directiveApi'
-
-interface DocumentSettingsProps {
- isOpen: boolean
- onClose: () => void
- enabled: boolean
- onToggle: (enabled: boolean) => void
-}
-
-export default function DocumentSettings({
- isOpen,
- onClose,
- enabled,
- onToggle,
-}: DocumentSettingsProps) {
- const [saving, setSaving] = useState(false)
-
- const handleToggle = useCallback(async () => {
- const newValue = !enabled
- setSaving(true)
- try {
- // Update localStorage immediately for instant UI response
- localStorage.setItem('document_ui_enabled', JSON.stringify(newValue))
- onToggle(newValue)
-
- // Persist to backend
- await upsertUserSetting('document_ui_enabled', newValue)
- } catch (err) {
- console.error('Failed to save document UI setting:', err)
- // Revert on failure
- localStorage.setItem('document_ui_enabled', JSON.stringify(!newValue))
- onToggle(!newValue)
- } finally {
- setSaving(false)
- }
- }, [enabled, onToggle])
-
- if (!isOpen) return null
-
- return (
- <div className="modal-overlay" onClick={onClose}>
- <div className="config-modal" onClick={(e) => e.stopPropagation()}>
- <div className="modal-header">
- <h2>Document UI Settings</h2>
- <button className="close-btn" onClick={onClose}>{'\u00D7'}</button>
- </div>
-
- <div className="modal-content">
- <div className="config-option">
- <label className="config-label" style={{ cursor: 'pointer' }}>
- <input
- type="checkbox"
- checked={enabled}
- onChange={handleToggle}
- disabled={saving}
- className="config-checkbox"
- />
- <span className="config-text">
- Enable Document UI (Experimental)
- </span>
- </label>
- <div className="config-description">
- Replace the directive management interface with an interactive
- document editor. This is a proof of concept.
- </div>
- </div>
- </div>
-
- <div className="modal-footer">
- <button className="modal-btn" onClick={onClose}>Close</button>
- </div>
- </div>
- </div>
- )
-}
diff --git a/frontend/src/components/document/EditorTheme.ts b/frontend/src/components/document/EditorTheme.ts
deleted file mode 100644
index 5b336ad..0000000
--- a/frontend/src/components/document/EditorTheme.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import type { EditorThemeClasses } from 'lexical';
-
-const editorTheme: EditorThemeClasses = {
- root: 'doc-editor-root',
- paragraph: 'doc-editor-paragraph',
- heading: {
- h1: 'doc-editor-h1',
- h2: 'doc-editor-h2',
- h3: 'doc-editor-h3',
- },
- text: {
- bold: 'doc-editor-text-bold',
- italic: 'doc-editor-text-italic',
- underline: 'doc-editor-text-underline',
- strikethrough: 'doc-editor-text-strikethrough',
- code: 'doc-editor-text-code',
- },
- list: {
- ul: 'doc-editor-list-ul',
- ol: 'doc-editor-list-ol',
- listitem: 'doc-editor-listitem',
- nested: {
- listitem: 'doc-editor-nested-listitem',
- },
- },
- link: 'doc-editor-link',
- placeholder: 'doc-editor-placeholder',
-};
-
-export default editorTheme;
diff --git a/frontend/src/components/document/Toast.css b/frontend/src/components/document/Toast.css
deleted file mode 100644
index e97304c..0000000
--- a/frontend/src/components/document/Toast.css
+++ /dev/null
@@ -1,100 +0,0 @@
-/* ============================================
- Toast Notifications
- ============================================ */
-
-.toast-container {
- position: fixed;
- bottom: 1.5rem;
- right: 1.5rem;
- z-index: 9999;
- display: flex;
- flex-direction: column-reverse;
- gap: 0.5rem;
- pointer-events: none;
-}
-
-.toast-item {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.65rem 1rem;
- border-radius: 8px;
- font-size: 0.875rem;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.08);
- pointer-events: auto;
- max-width: 360px;
- backdrop-filter: blur(6px);
-}
-
-/* Types */
-.toast-success {
- background: #ecfdf5;
- color: #065f46;
- border: 1px solid #a7f3d0;
-}
-
-.toast-error {
- background: #fef2f2;
- color: #991b1b;
- border: 1px solid #fecaca;
-}
-
-.toast-info {
- background: #eff6ff;
- color: #1e40af;
- border: 1px solid #bfdbfe;
-}
-
-/* Icon */
-.toast-icon {
- flex-shrink: 0;
- font-size: 1rem;
- line-height: 1;
-}
-
-.toast-message {
- line-height: 1.4;
-}
-
-/* Animations */
-.toast-enter {
- animation: toastSlideIn 0.25s ease-out forwards;
-}
-
-.toast-exit {
- animation: toastSlideOut 0.3s ease-in forwards;
-}
-
-@keyframes toastSlideIn {
- from {
- opacity: 0;
- transform: translateY(8px) scale(0.96);
- }
- to {
- opacity: 1;
- transform: translateY(0) scale(1);
- }
-}
-
-@keyframes toastSlideOut {
- from {
- opacity: 1;
- transform: translateY(0) scale(1);
- }
- to {
- opacity: 0;
- transform: translateY(8px) scale(0.96);
- }
-}
-
-@media (max-width: 640px) {
- .toast-container {
- right: 0.75rem;
- bottom: 0.75rem;
- }
- .toast-item {
- max-width: calc(100vw - 1.5rem);
- font-size: 0.8rem;
- }
-}
diff --git a/frontend/src/components/document/Toast.tsx b/frontend/src/components/document/Toast.tsx
deleted file mode 100644
index 653db8f..0000000
--- a/frontend/src/components/document/Toast.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-import {
- createContext,
- useCallback,
- useContext,
- useEffect,
- useRef,
- useState,
- type ReactNode,
-} from 'react';
-import './Toast.css';
-
-// -- Types -------------------------------------------------------------------
-
-export type ToastType = 'success' | 'error' | 'info';
-
-interface ToastItem {
- id: number;
- message: string;
- type: ToastType;
-}
-
-interface ToastContextValue {
- addToast: (message: string, type?: ToastType) => void;
-}
-
-// -- Context -----------------------------------------------------------------
-
-const ToastContext = createContext<ToastContextValue | null>(null);
-
-export function useToast(): ToastContextValue {
- const ctx = useContext(ToastContext);
- if (!ctx) throw new Error('useToast must be used within a ToastProvider');
- return ctx;
-}
-
-// -- Provider ----------------------------------------------------------------
-
-const DISMISS_MS = 3000;
-
-export function ToastProvider({ children }: { children: ReactNode }) {
- const [toasts, setToasts] = useState<ToastItem[]>([]);
- const nextId = useRef(0);
-
- const addToast = useCallback((message: string, type: ToastType = 'info') => {
- const id = nextId.current++;
- setToasts((prev) => [...prev, { id, message, type }]);
- }, []);
-
- const removeToast = useCallback((id: number) => {
- setToasts((prev) => prev.filter((t) => t.id !== id));
- }, []);
-
- return (
- <ToastContext.Provider value={{ addToast }}>
- {children}
- <div className="toast-container">
- {toasts.map((t) => (
- <ToastItem key={t.id} toast={t} onDismiss={removeToast} />
- ))}
- </div>
- </ToastContext.Provider>
- );
-}
-
-// -- Single toast ------------------------------------------------------------
-
-function ToastItem({
- toast,
- onDismiss,
-}: {
- toast: ToastItem;
- onDismiss: (id: number) => void;
-}) {
- const [exiting, setExiting] = useState(false);
-
- useEffect(() => {
- const timer = setTimeout(() => setExiting(true), DISMISS_MS - 300);
- const remove = setTimeout(() => onDismiss(toast.id), DISMISS_MS);
- return () => {
- clearTimeout(timer);
- clearTimeout(remove);
- };
- }, [toast.id, onDismiss]);
-
- const icon =
- toast.type === 'success' ? '\u2713' : toast.type === 'error' ? '\u2717' : '\u2139';
-
- return (
- <div
- className={`toast-item toast-${toast.type} ${exiting ? 'toast-exit' : 'toast-enter'}`}
- role="status"
- >
- <span className="toast-icon">{icon}</span>
- <span className="toast-message">{toast.message}</span>
- </div>
- );
-}
diff --git a/frontend/src/components/document/index.ts b/frontend/src/components/document/index.ts
index 3217a1b..906c1dc 100644
--- a/frontend/src/components/document/index.ts
+++ b/frontend/src/components/document/index.ts
@@ -11,3 +11,4 @@ export { ContractBlockNode, $createContractBlockNode, $isContractBlockNode } fro
export { StepsDiagramComponent } from './nodes/StepsDiagramComponent'
export { ContractBlockComponent } from './nodes/ContractBlockComponent'
export { StepLogFeed } from './nodes/StepLogFeed'
+export { ContractLogFeed } from './nodes/ContractLogFeed'
diff --git a/frontend/src/components/document/nodes/ContractBlock.css b/frontend/src/components/document/nodes/ContractBlock.css
deleted file mode 100644
index 80edb74..0000000
--- a/frontend/src/components/document/nodes/ContractBlock.css
+++ /dev/null
@@ -1,123 +0,0 @@
-/* ============================================
- Contract Block - Inline contract reference
- ============================================ */
-
-.contract-block-wrapper {
- margin: 1rem 0;
- user-select: none;
-}
-
-.contract-block {
- background: #fafbff;
- border: 1px solid #e2e5ef;
- border-radius: 8px;
- padding: 0.65rem 0.85rem;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
- font-size: 13px;
- color: #374151;
- transition: box-shadow 0.2s ease, border-color 0.2s ease;
- animation: contractBlockAppear 0.25s ease-out both;
-}
-
-@keyframes contractBlockAppear {
- from {
- opacity: 0;
- transform: translateY(4px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-.contract-block:hover {
- border-color: #c7cce0;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
-}
-
-.contract-block--error {
- border-color: #fecaca;
- background: #fef2f2;
-}
-
-/* Header */
-.contract-block-header {
- display: flex;
- align-items: center;
- gap: 0.5rem;
-}
-
-.contract-block-icon {
- font-size: 1rem;
- flex-shrink: 0;
-}
-
-.contract-block-name {
- font-weight: 600;
- font-size: 0.88rem;
- color: #1f2937;
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.contract-block-phase-badge {
- font-size: 0.68rem;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.03em;
- padding: 0.1rem 0.4rem;
- border-radius: 8px;
- white-space: nowrap;
- flex-shrink: 0;
-}
-
-.contract-block-status-dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- flex-shrink: 0;
-}
-
-/* Meta */
-.contract-block-meta {
- margin-top: 0.3rem;
- padding-left: 1.5rem;
-}
-
-.contract-block-type {
- font-size: 0.75rem;
- color: #9ca3af;
- font-style: italic;
-}
-
-.contract-block-error-msg {
- margin-top: 0.25rem;
- font-size: 0.78rem;
- color: #dc2626;
- padding-left: 1.5rem;
-}
-
-/* Loading state */
-.contract-block-loading {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.4rem 0;
- color: #9ca3af;
- font-size: 0.82rem;
-}
-
-.contract-block-spinner {
- width: 14px;
- height: 14px;
- border: 2px solid #e5e7eb;
- border-top-color: #6b7280;
- border-radius: 50%;
- animation: spin 0.8s linear infinite;
-}
-
-@keyframes spin {
- to { transform: rotate(360deg); }
-}
diff --git a/frontend/src/components/document/nodes/ContractBlockComponent.tsx b/frontend/src/components/document/nodes/ContractBlockComponent.tsx
deleted file mode 100644
index 0d9a25a..0000000
--- a/frontend/src/components/document/nodes/ContractBlockComponent.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import './ContractBlock.css';
-
-interface ContractBlockComponentProps {
- contractId: string;
- contractName: string;
-}
-
-interface ContractInfo {
- id: string;
- name: string;
- status: string;
- phase: string;
- contract_type: string;
-}
-
-const PHASE_COLORS: Record<string, string> = {
- planning: '#3b82f6',
- execution: '#f59e0b',
- review: '#8b5cf6',
- completed: '#10b981',
- failed: '#ef4444',
-};
-
-const STATUS_COLORS: Record<string, string> = {
- active: '#10b981',
- running: '#10b981',
- idle: '#f59e0b',
- paused: '#f59e0b',
- completed: '#10b981',
- failed: '#ef4444',
- archived: '#6b7280',
-};
-
-export function ContractBlockComponent({ contractId, contractName }: ContractBlockComponentProps) {
- const [contract, setContract] = useState<ContractInfo | null>(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState<string | null>(null);
-
- useEffect(() => {
- let cancelled = false;
-
- async function fetchContract() {
- try {
- const response = await fetch(`/api/v1/contracts/${contractId}`);
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
- const data = await response.json();
- if (!cancelled) {
- setContract(data.contract || data);
- setError(null);
- }
- } catch (err) {
- if (!cancelled) {
- setError(err instanceof Error ? err.message : 'Failed to load');
- }
- } finally {
- if (!cancelled) setLoading(false);
- }
- }
-
- fetchContract();
- return () => { cancelled = true; };
- }, [contractId]);
-
- if (loading) {
- return (
- <div className="contract-block" contentEditable={false}>
- <div className="contract-block-loading">
- <div className="contract-block-spinner" />
- <span>Loading contract...</span>
- </div>
- </div>
- );
- }
-
- if (error) {
- return (
- <div className="contract-block contract-block--error" contentEditable={false}>
- <div className="contract-block-header">
- <span className="contract-block-icon">&#x1F4E6;</span>
- <span className="contract-block-name">{contractName}</span>
- </div>
- <div className="contract-block-error-msg">Unable to load: {error}</div>
- </div>
- );
- }
-
- const phase = contract?.phase?.toLowerCase() || 'unknown';
- const status = contract?.status?.toLowerCase() || 'unknown';
- const phaseColor = PHASE_COLORS[phase] || '#6b7280';
- const statusColor = STATUS_COLORS[status] || '#6b7280';
-
- return (
- <div className="contract-block" contentEditable={false}>
- <div className="contract-block-header">
- <span className="contract-block-icon">&#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>
- {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
deleted file mode 100644
index 86e4c9d..0000000
--- a/frontend/src/components/document/nodes/ContractBlockNode.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import {
- DecoratorNode,
- DOMExportOutput,
- LexicalNode,
- NodeKey,
- SerializedLexicalNode,
- Spread,
-} from 'lexical';
-import React from 'react';
-import { ContractBlockComponent } from './ContractBlockComponent';
-
-export type SerializedContractBlockNode = Spread<
- {
- contractId: string;
- contractName: string;
- },
- SerializedLexicalNode
->;
-
-export class ContractBlockNode extends DecoratorNode<JSX.Element> {
- __contractId: string;
- __contractName: string;
-
- static getType(): string {
- return 'contract-block';
- }
-
- static clone(node: ContractBlockNode): ContractBlockNode {
- return new ContractBlockNode(node.__contractId, node.__contractName, node.__key);
- }
-
- constructor(contractId: string, contractName: string, key?: NodeKey) {
- super(key);
- this.__contractId = contractId;
- this.__contractName = contractName;
- }
-
- createDOM(): HTMLElement {
- const div = document.createElement('div');
- div.className = 'contract-block-wrapper';
- return div;
- }
-
- updateDOM(): boolean {
- return false;
- }
-
- decorate(): JSX.Element {
- return (
- <ContractBlockComponent
- contractId={this.__contractId}
- contractName={this.__contractName}
- />
- );
- }
-
- exportJSON(): SerializedContractBlockNode {
- return {
- ...super.exportJSON(),
- type: 'contract-block',
- contractId: this.__contractId,
- contractName: this.__contractName,
- version: 1,
- };
- }
-
- static importJSON(serializedNode: SerializedContractBlockNode): ContractBlockNode {
- return $createContractBlockNode(
- serializedNode.contractId,
- serializedNode.contractName
- );
- }
-
- isInline(): boolean {
- return false;
- }
-
- canInsertTextBefore(): boolean {
- return false;
- }
-
- canInsertTextAfter(): boolean {
- return false;
- }
-
- exportDOM(): DOMExportOutput {
- const element = document.createElement('div');
- element.className = 'contract-block-wrapper';
- element.setAttribute('data-contract-id', this.__contractId);
- element.textContent = `[Contract: ${this.__contractName}]`;
- return { element };
- }
-}
-
-export function $createContractBlockNode(
- contractId: string,
- contractName: string
-): ContractBlockNode {
- return new ContractBlockNode(contractId, contractName);
-}
-
-export function $isContractBlockNode(
- node: LexicalNode | null | undefined,
-): node is ContractBlockNode {
- return node instanceof ContractBlockNode;
-}
diff --git a/frontend/src/components/document/nodes/ContractLogFeed.css b/frontend/src/components/document/nodes/ContractLogFeed.css
deleted file mode 100644
index b5dd15d..0000000
--- a/frontend/src/components/document/nodes/ContractLogFeed.css
+++ /dev/null
@@ -1,346 +0,0 @@
-/* ============================================
- Contract Log Feed
- ============================================ */
-
-.contract-log-feed {
- display: flex;
- flex-direction: column;
- background: #1a1d23;
- border: 1px solid #2d3039;
- border-radius: 8px;
- overflow: hidden;
- margin-top: 0.5rem;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
- font-size: 13px;
- max-height: 420px;
- animation: logFeedSlideIn 0.25s ease-out;
-}
-
-@keyframes logFeedSlideIn {
- from {
- opacity: 0;
- transform: translateY(-6px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-/* ---- Header ---- */
-.contract-log-feed-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0.5rem 0.75rem;
- background: #22252b;
- border-bottom: 1px solid #2d3039;
-}
-
-.contract-log-feed-title {
- display: flex;
- align-items: center;
- gap: 0.5rem;
-}
-
-.contract-log-feed-name {
- font-weight: 600;
- font-size: 0.82rem;
- color: #e5e7eb;
-}
-
-.contract-log-feed-status {
- font-size: 0.65rem;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- padding: 0.1rem 0.4rem;
- border-radius: 8px;
-}
-
-.contract-log-feed-status--running,
-.contract-log-feed-status--starting {
- background: rgba(245, 158, 11, 0.2);
- color: #fbbf24;
- animation: statusPulse 2s ease-in-out infinite;
-}
-
-.contract-log-feed-status--completed {
- background: rgba(16, 185, 129, 0.2);
- color: #34d399;
-}
-
-.contract-log-feed-status--failed {
- background: rgba(239, 68, 68, 0.2);
- color: #f87171;
-}
-
-.contract-log-feed-status--pending,
-.contract-log-feed-status--ready {
- background: rgba(107, 114, 128, 0.2);
- color: #9ca3af;
-}
-
-.contract-log-feed-close {
- background: none;
- border: none;
- color: #6b7280;
- font-size: 1.1rem;
- cursor: pointer;
- padding: 0 0.25rem;
- line-height: 1;
- border-radius: 3px;
- transition: color 0.15s, background 0.15s;
-}
-
-.contract-log-feed-close:hover {
- color: #e5e7eb;
- background: rgba(255, 255, 255, 0.08);
-}
-
-/* ---- Log Content ---- */
-.contract-log-feed-content {
- flex: 1;
- overflow-y: auto;
- padding: 0.5rem 0.75rem;
- min-height: 80px;
- max-height: 240px;
- scrollbar-width: thin;
- scrollbar-color: #3a3f4b transparent;
-}
-
-.contract-log-feed-content::-webkit-scrollbar {
- width: 5px;
-}
-
-.contract-log-feed-content::-webkit-scrollbar-thumb {
- background: #3a3f4b;
- border-radius: 3px;
-}
-
-.contract-log-feed-empty {
- color: #6b7280;
- font-size: 0.82rem;
- font-style: italic;
- text-align: center;
- padding: 1.5rem 0;
-}
-
-/* ---- Log Entry ---- */
-.contract-log-entry {
- display: flex;
- gap: 0.5rem;
- padding: 0.2rem 0;
- line-height: 1.5;
- animation: entryFadeIn 0.2s ease-out;
-}
-
-@keyframes entryFadeIn {
- from { opacity: 0; }
- to { opacity: 1; }
-}
-
-.contract-log-entry-time {
- flex-shrink: 0;
- font-size: 0.7rem;
- color: #4b5563;
- font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
- line-height: 1.65;
-}
-
-.contract-log-entry-text {
- color: #d1d5db;
- white-space: pre-wrap;
- word-break: break-word;
- font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
- font-size: 0.78rem;
-}
-
-.contract-log-entry--user .contract-log-entry-text {
- color: #93c5fd;
-}
-
-.contract-log-entry--user::before {
- content: '>';
- color: #3b82f6;
- font-weight: 700;
- font-family: monospace;
- flex-shrink: 0;
- line-height: 1.5;
-}
-
-.contract-log-entry--system .contract-log-entry-text {
- color: #fbbf24;
- font-style: italic;
-}
-
-/* ---- Error ---- */
-.contract-log-feed-error {
- padding: 0.4rem 0.75rem;
- background: rgba(239, 68, 68, 0.12);
- border-top: 1px solid rgba(239, 68, 68, 0.25);
- color: #f87171;
- font-size: 0.78rem;
-}
-
-/* ---- Interaction Bar ---- */
-.contract-interaction-bar {
- border-top: 1px solid #2d3039;
- padding: 0.5rem 0.75rem;
- background: #22252b;
-}
-
-.contract-interaction-bar--disabled {
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0.6rem 0.75rem;
-}
-
-.contract-interaction-disabled-text {
- color: #6b7280;
- font-size: 0.78rem;
- font-style: italic;
-}
-
-.contract-interaction-message-row {
- display: flex;
- align-items: flex-end;
- gap: 0.4rem;
- position: relative;
-}
-
-.contract-message-input {
- flex: 1;
- background: #1a1d23;
- border: 1px solid #3a3f4b;
- border-radius: 6px;
- color: #e5e7eb;
- padding: 0.4rem 0.6rem;
- font-size: 0.82rem;
- font-family: inherit;
- resize: none;
- min-height: 32px;
- max-height: 80px;
- line-height: 1.4;
- outline: none;
- transition: border-color 0.15s;
-}
-
-.contract-message-input::placeholder {
- color: #4b5563;
- font-size: 0.78rem;
-}
-
-.contract-message-input:focus {
- border-color: #3b82f6;
-}
-
-.contract-message-input:disabled {
- opacity: 0.5;
- cursor: not-allowed;
-}
-
-.contract-send-btn {
- flex-shrink: 0;
- background: #3b82f6;
- color: #fff;
- border: none;
- border-radius: 6px;
- padding: 0.4rem 0.85rem;
- font-size: 0.8rem;
- font-weight: 500;
- cursor: pointer;
- transition: background 0.15s, opacity 0.15s;
- min-height: 32px;
-}
-
-.contract-send-btn:hover:not(:disabled) {
- background: #2563eb;
-}
-
-.contract-send-btn:disabled {
- opacity: 0.4;
- cursor: not-allowed;
-}
-
-.contract-sent-indicator {
- position: absolute;
- right: 0;
- top: -1.4rem;
- font-size: 0.7rem;
- color: #34d399;
- font-weight: 500;
- animation: sentFlash 1.5s ease-out forwards;
-}
-
-@keyframes sentFlash {
- 0% {
- opacity: 1;
- transform: translateY(0);
- }
- 70% {
- opacity: 1;
- }
- 100% {
- opacity: 0;
- transform: translateY(-4px);
- }
-}
-
-/* ---- Actions Row ---- */
-.contract-interaction-actions-row {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- margin-top: 0.4rem;
-}
-
-.contract-interrupt-btn {
- background: transparent;
- color: #ef4444;
- border: 1px solid rgba(239, 68, 68, 0.3);
- border-radius: 6px;
- padding: 0.3rem 0.7rem;
- font-size: 0.75rem;
- font-weight: 500;
- cursor: pointer;
- transition: background 0.15s, border-color 0.15s, color 0.15s;
-}
-
-.contract-interrupt-btn:hover:not(:disabled) {
- background: rgba(239, 68, 68, 0.1);
- border-color: rgba(239, 68, 68, 0.5);
-}
-
-.contract-interrupt-btn--confirm {
- background: rgba(239, 68, 68, 0.15);
- border-color: #ef4444;
- color: #f87171;
- animation: confirmPulse 0.8s ease-in-out infinite;
-}
-
-@keyframes confirmPulse {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.7; }
-}
-
-.contract-interrupt-btn:disabled {
- opacity: 0.4;
- cursor: not-allowed;
-}
-
-/* ---- Responsive ---- */
-@media (max-width: 640px) {
- .contract-log-feed {
- max-height: 360px;
- }
-
- .contract-log-feed-content {
- max-height: 180px;
- }
-
- .contract-message-input::placeholder {
- font-size: 0.72rem;
- }
-}
diff --git a/frontend/src/components/document/nodes/ContractLogFeed.tsx b/frontend/src/components/document/nodes/ContractLogFeed.tsx
deleted file mode 100644
index 79af91c..0000000
--- a/frontend/src/components/document/nodes/ContractLogFeed.tsx
+++ /dev/null
@@ -1,225 +0,0 @@
-import React, { useState, useRef, useEffect, useCallback } from 'react';
-import {
- sendContractMessage,
- interruptContract,
- getContractOutput,
-} from '../../../services/directiveApi';
-import './ContractLogFeed.css';
-
-interface ContractLogFeedProps {
- taskId: string;
- contractName: string;
- status: string;
- onClose?: () => void;
-}
-
-interface LogEntry {
- id: string;
- text: string;
- type: 'output' | 'user' | 'system';
- timestamp: Date;
-}
-
-const INTERACTIVE_STATUSES = ['running', 'starting'];
-
-export function ContractLogFeed({ taskId, contractName, status, onClose }: ContractLogFeedProps) {
- const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
- const [message, setMessage] = useState('');
- const [sending, setSending] = useState(false);
- const [sentIndicator, setSentIndicator] = useState(false);
- const [interruptConfirm, setInterruptConfirm] = useState(false);
- const [interrupting, setInterrupting] = useState(false);
- const [error, setError] = useState<string | null>(null);
- const logEndRef = useRef<HTMLDivElement>(null);
- const textareaRef = useRef<HTMLTextAreaElement>(null);
- const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
- const lastOutputRef = useRef<string>('');
- const entryIdRef = useRef(0);
-
- const isInteractive = INTERACTIVE_STATUSES.includes(status.toLowerCase());
-
- const addLogEntry = useCallback((text: string, type: LogEntry['type']) => {
- entryIdRef.current += 1;
- setLogEntries(prev => [
- ...prev,
- { id: `entry-${entryIdRef.current}`, text, type, timestamp: new Date() },
- ]);
- }, []);
-
- // Poll for contract output
- const fetchOutput = useCallback(async () => {
- if (!taskId) return;
- try {
- const data = await getContractOutput(taskId);
- const output = data.output || '';
- if (output && output !== lastOutputRef.current) {
- // Find new content
- const newContent = output.startsWith(lastOutputRef.current)
- ? output.slice(lastOutputRef.current.length).trim()
- : output.trim();
- lastOutputRef.current = output;
- if (newContent) {
- addLogEntry(newContent, 'output');
- }
- }
- } catch {
- // Silently ignore fetch errors for output polling
- }
- }, [taskId, addLogEntry]);
-
- useEffect(() => {
- fetchOutput();
- pollRef.current = setInterval(fetchOutput, 3000);
- return () => {
- if (pollRef.current) clearInterval(pollRef.current);
- };
- }, [fetchOutput]);
-
- // Auto-scroll to bottom on new entries
- useEffect(() => {
- logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
- }, [logEntries]);
-
- // Reset interrupt confirm after timeout
- useEffect(() => {
- if (!interruptConfirm) return;
- const timer = setTimeout(() => setInterruptConfirm(false), 3000);
- return () => clearTimeout(timer);
- }, [interruptConfirm]);
-
- const handleSendMessage = async () => {
- const trimmed = message.trim();
- if (!trimmed || sending || !isInteractive) return;
-
- setSending(true);
- setError(null);
- try {
- await sendContractMessage(taskId, trimmed);
- addLogEntry(trimmed, 'user');
- setMessage('');
- setSentIndicator(true);
- setTimeout(() => setSentIndicator(false), 1500);
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to send message');
- } finally {
- setSending(false);
- }
- };
-
- const handleInterrupt = async () => {
- if (!interruptConfirm) {
- setInterruptConfirm(true);
- return;
- }
-
- setInterrupting(true);
- setError(null);
- setInterruptConfirm(false);
- try {
- await interruptContract(taskId);
- addLogEntry('Contract interrupted by user', 'system');
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to interrupt contract');
- } finally {
- setInterrupting(false);
- }
- };
-
- const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- handleSendMessage();
- }
- };
-
- const statusLower = status.toLowerCase();
-
- return (
- <div className="contract-log-feed">
- <div className="contract-log-feed-header">
- <div className="contract-log-feed-title">
- <span className="contract-log-feed-name">{contractName}</span>
- <span className={`contract-log-feed-status contract-log-feed-status--${statusLower}`}>
- {status}
- </span>
- </div>
- {onClose && (
- <button className="contract-log-feed-close" onClick={onClose} title="Close">
- &times;
- </button>
- )}
- </div>
-
- <div className="contract-log-feed-content">
- {logEntries.length === 0 && (
- <div className="contract-log-feed-empty">
- {isInteractive ? 'Waiting for output...' : 'No output available.'}
- </div>
- )}
- {logEntries.map(entry => (
- <div key={entry.id} className={`contract-log-entry contract-log-entry--${entry.type}`}>
- <span className="contract-log-entry-time">
- {entry.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
- </span>
- <span className="contract-log-entry-text">{entry.text}</span>
- </div>
- ))}
- <div ref={logEndRef} />
- </div>
-
- {error && (
- <div className="contract-log-feed-error">{error}</div>
- )}
-
- {isInteractive && (
- <div className="contract-interaction-bar">
- <div className="contract-interaction-message-row">
- <textarea
- ref={textareaRef}
- className="contract-message-input"
- value={message}
- onChange={e => setMessage(e.target.value)}
- onKeyDown={handleKeyDown}
- placeholder="Send a message to the contract... (Enter to send, Shift+Enter for newline)"
- rows={1}
- disabled={sending}
- />
- <button
- className="contract-send-btn"
- onClick={handleSendMessage}
- disabled={sending || !message.trim()}
- title="Send message"
- >
- {sending ? 'Sending...' : 'Send'}
- </button>
- {sentIndicator && (
- <span className="contract-sent-indicator">Sent</span>
- )}
- </div>
- <div className="contract-interaction-actions-row">
- <button
- className={`contract-interrupt-btn ${interruptConfirm ? 'contract-interrupt-btn--confirm' : ''}`}
- onClick={handleInterrupt}
- disabled={interrupting}
- title={interruptConfirm ? 'Click again to confirm interrupt' : 'Interrupt contract'}
- >
- {interrupting
- ? 'Interrupting...'
- : interruptConfirm
- ? 'Click again to confirm interrupt'
- : 'Interrupt'}
- </button>
- </div>
- </div>
- )}
-
- {!isInteractive && statusLower !== 'pending' && statusLower !== 'ready' && (
- <div className="contract-interaction-bar contract-interaction-bar--disabled">
- <span className="contract-interaction-disabled-text">
- Contract is {status.toLowerCase()} - interaction unavailable
- </span>
- </div>
- )}
- </div>
- );
-}
diff --git a/frontend/src/components/document/nodes/StepLogFeed.tsx b/frontend/src/components/document/nodes/StepLogFeed.tsx
index 0357de8..2f2f553 100644
--- a/frontend/src/components/document/nodes/StepLogFeed.tsx
+++ b/frontend/src/components/document/nodes/StepLogFeed.tsx
@@ -211,7 +211,7 @@ export function StepLogFeed({ taskId, stepName, stepStatus, onCollapse }: StepLo
<button
className="step-log-feed-interrupt-btn"
onClick={handleInterrupt}
- title="Interrupt this task"
+ title="Interrupt this contract"
>
&#x23F9; Interrupt
</button>
@@ -256,7 +256,7 @@ export function StepLogFeed({ taskId, stepName, stepStatus, onCollapse }: StepLo
ref={inputRef}
type="text"
className="step-log-feed-input-field"
- placeholder="Send a message to this task..."
+ placeholder="Send a message to this contract..."
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
diff --git a/frontend/src/components/document/nodes/StepsDiagram.css b/frontend/src/components/document/nodes/StepsDiagram.css
deleted file mode 100644
index 9856c6d..0000000
--- a/frontend/src/components/document/nodes/StepsDiagram.css
+++ /dev/null
@@ -1,683 +0,0 @@
-/* ============================================
- Steps Diagram Block
- ============================================ */
-
-.steps-diagram-block {
- margin: 1.5rem 0;
- user-select: none;
-}
-
-.steps-diagram {
- background: #f8f9fc;
- border: 1px solid #e2e5ef;
- border-radius: 10px;
- padding: 1rem 1.25rem;
- 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 ---- */
-.steps-diagram-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 1rem;
- padding-bottom: 0.6rem;
- border-bottom: 1px solid #e5e7eb;
-}
-
-.steps-diagram-header-left {
- display: flex;
- align-items: center;
- gap: 0.75rem;
-}
-
-.steps-diagram-header-title {
- font-weight: 600;
- font-size: 0.9rem;
- color: #1f2937;
- letter-spacing: 0.01em;
-}
-
-.steps-diagram-header-count {
- font-size: 0.78rem;
- color: #6b7280;
- background: #e5e7eb;
- border-radius: 10px;
- padding: 0.15rem 0.55rem;
-}
-
-.steps-diagram-header-author {
- font-size: 0.72rem;
- color: #9ca3af;
- font-style: italic;
-}
-
-/* ---- DAG Layout ---- */
-.steps-diagram-dag {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 0;
-}
-
-.steps-diagram-group {
- display: flex;
- flex-wrap: wrap;
- gap: 0.6rem;
- justify-content: center;
- width: 100%;
-}
-
-/* ---- Arrow between groups ---- */
-.steps-diagram-arrow {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 0.15rem 0;
-}
-
-.steps-diagram-arrow-line {
- width: 2px;
- height: 16px;
- background: #cbd5e1;
-}
-
-.steps-diagram-arrow-head {
- width: 0;
- height: 0;
- border-left: 5px solid transparent;
- border-right: 5px solid transparent;
- border-top: 6px solid #cbd5e1;
-}
-
-/* ---- Step Card Wrapper ---- */
-.steps-diagram-card-wrapper {
- flex: 1 1 180px;
- max-width: 280px;
-}
-
-/* ---- Step Card ---- */
-.steps-diagram-card {
- background: #ffffff;
- border: 1px solid #e5e7eb;
- border-radius: 8px;
- padding: 0.65rem 0.8rem;
- transition: box-shadow 0.2s ease, border-color 0.2s ease, max-width 0.3s ease;
- animation: stepCardAppear 0.35s ease-out both;
-}
-
-.steps-diagram-card--expanded {
- flex: 1 1 100%;
- max-width: 100%;
- border-color: #93c5fd;
- box-shadow: 0 2px 12px rgba(59, 130, 246, 0.1);
-}
-
-@keyframes stepCardAppear {
- from {
- opacity: 0;
- transform: translateY(8px) scale(0.97);
- }
- to {
- opacity: 1;
- transform: translateY(0) scale(1);
- }
-}
-
-.steps-diagram-card:hover {
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
-}
-
-.steps-diagram-card--expandable {
- cursor: pointer;
-}
-
-.steps-diagram-card--expandable:hover {
- border-color: #c7cbd5;
-}
-
-.steps-diagram-card--expanded {
- border-radius: 8px 8px 0 0;
- border-bottom-color: transparent;
-}
-
-.steps-diagram-card-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 0.5rem;
- 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;
- color: #1f2937;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- flex: 1;
- transition: color 0.15s;
-}
-
-.steps-diagram-card-desc {
- font-size: 0.78rem;
- color: #6b7280;
- margin: 0.2rem 0 0.4rem 0;
- line-height: 1.4;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-.steps-diagram-card-footer {
- display: flex;
- align-items: center;
- justify-content: space-between;
- font-size: 0.72rem;
- color: #9ca3af;
-}
-
-.steps-diagram-card-index {
- font-weight: 500;
-}
-
-.steps-diagram-card-contract-ref {
- font-family: 'SF Mono', SFMono-Regular, ui-monospace, Menlo, monospace;
- font-size: 0.68rem;
- color: #6b7280;
- background: #f3f4f6;
- padding: 0.08rem 0.35rem;
- border-radius: 4px;
- cursor: default;
-}
-
-.steps-diagram-card-progress {
- color: #d97706;
- font-style: italic;
-}
-
-.steps-diagram-card-time {
- color: #6b7280;
-}
-
-/* ---- Expand Icon ---- */
-.steps-diagram-expand-icon {
- font-size: 0.6rem;
- color: #9ca3af;
- transition: transform 0.2s ease, color 0.15s;
- display: inline-block;
-}
-
-.steps-diagram-expand-icon.expanded {
- transform: rotate(90deg);
- color: #3b82f6;
-}
-
-.steps-diagram-card-header--clickable:hover .steps-diagram-expand-icon {
- color: #3b82f6;
-}
-
-/* ---- Status Badge ---- */
-.steps-diagram-status-badge {
- font-size: 0.68rem;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.03em;
- padding: 0.12rem 0.45rem;
- border-radius: 9px;
- white-space: nowrap;
- flex-shrink: 0;
-}
-
-.steps-diagram-status-badge--pending {
- background: #f3f4f6;
- color: #6b7280;
-}
-
-.steps-diagram-status-badge--ready {
- background: #dbeafe;
- color: #2563eb;
-}
-
-.steps-diagram-status-badge--running {
- background: #fef3c7;
- color: #d97706;
- animation: statusPulse 2s ease-in-out infinite;
-}
-
-.steps-diagram-status-badge--completed {
- background: #d1fae5;
- color: #059669;
-}
-
-.steps-diagram-status-badge--failed {
- background: #fee2e2;
- color: #dc2626;
-}
-
-.steps-diagram-status-badge--skipped {
- background: repeating-linear-gradient(
- 45deg,
- #f3f4f6,
- #f3f4f6 4px,
- #e5e7eb 4px,
- #e5e7eb 8px
- );
- color: #9ca3af;
-}
-
-/* ---- Status-specific Card Borders ---- */
-.steps-diagram-card--pending {
- border-left: 3px solid #d1d5db;
-}
-
-.steps-diagram-card--ready {
- border-left: 3px solid #3b82f6;
-}
-
-.steps-diagram-card--running {
- border-left: 3px solid #f59e0b;
- animation: cardGlow 2s ease-in-out infinite;
-}
-
-.steps-diagram-card--completed {
- border-left: 3px solid #10b981;
-}
-
-.steps-diagram-card--failed {
- border-left: 3px solid #ef4444;
-}
-
-.steps-diagram-card--skipped {
- border-left: 3px solid #d1d5db;
- opacity: 0.7;
-}
-
-/* ---- Expanded Card ---- */
-.steps-diagram-card--expanded {
- flex: 1 1 100%;
- max-width: 100%;
-}
-
-.steps-diagram-card-expand {
- flex-shrink: 0;
- font-size: 0.7rem;
- color: #9ca3af;
- margin-left: 0.25rem;
-}
-
-/* ---- Animations ---- */
-@keyframes statusPulse {
- 0%, 100% {
- opacity: 1;
- }
- 50% {
- opacity: 0.65;
- }
-}
-
-@keyframes cardGlow {
- 0%, 100% {
- box-shadow: 0 0 0 0 rgba(245, 158, 11, 0);
- }
- 50% {
- box-shadow: 0 0 8px 2px rgba(245, 158, 11, 0.15);
- }
-}
-
-/* ---- Loading State ---- */
-.steps-diagram-loading {
- display: flex;
- align-items: center;
- gap: 0.6rem;
- padding: 1rem 0;
- color: #9ca3af;
- font-size: 0.85rem;
-}
-
-.steps-diagram-spinner {
- width: 16px;
- height: 16px;
- border: 2px solid #e5e7eb;
- border-top-color: #6b7280;
- border-radius: 50%;
- animation: spin 0.8s linear infinite;
-}
-
-@keyframes spin {
- to { transform: rotate(360deg); }
-}
-
-/* ---- Planning State ---- */
-.steps-diagram-planning {
- display: flex;
- align-items: center;
- gap: 0.75rem;
- padding: 1.25rem 0;
- color: #6b7280;
- font-size: 0.85rem;
- font-style: italic;
-}
-
-.steps-diagram-planning-dots {
- display: flex;
- gap: 4px;
-}
-
-.steps-diagram-planning-dots span {
- width: 6px;
- height: 6px;
- background: #9ca3af;
- border-radius: 50%;
- animation: dotBounce 1.4s ease-in-out infinite;
-}
-
-.steps-diagram-planning-dots span:nth-child(2) {
- animation-delay: 0.2s;
-}
-
-.steps-diagram-planning-dots span:nth-child(3) {
- animation-delay: 0.4s;
-}
-
-@keyframes dotBounce {
- 0%, 80%, 100% {
- transform: scale(0.6);
- opacity: 0.4;
- }
- 40% {
- transform: scale(1);
- opacity: 1;
- }
-}
-
-/* ---- Empty / Error ---- */
-.steps-diagram-empty {
- padding: 1rem 0;
- color: #9ca3af;
- font-size: 0.85rem;
- text-align: center;
-}
-
-.steps-diagram-error {
- padding: 0.75rem;
- background: #fef2f2;
- border: 1px solid #fecaca;
- border-radius: 6px;
- color: #dc2626;
- font-size: 0.82rem;
-}
-
-/* ============================================
- Step Log Feed (Expandable)
- ============================================ */
-
-.step-log-feed {
- margin-top: 0.5rem;
- border-top: 1px solid #e5e7eb;
- padding-top: 0.5rem;
- animation: logFeedSlideIn 0.25s ease-out both;
-}
-
-@keyframes logFeedSlideIn {
- from {
- opacity: 0;
- max-height: 0;
- }
- to {
- opacity: 1;
- max-height: 500px;
- }
-}
-
-.step-log-feed-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding-bottom: 0.4rem;
- margin-bottom: 0.4rem;
- border-bottom: 1px solid #f3f4f6;
-}
-
-.step-log-feed-header-left {
- display: flex;
- align-items: center;
- gap: 0.5rem;
-}
-
-.step-log-feed-header-right {
- display: flex;
- align-items: center;
- gap: 0.35rem;
-}
-
-.step-log-feed-title {
- font-size: 0.75rem;
- font-weight: 600;
- color: #4b5563;
-}
-
-.step-log-feed-status {
- font-size: 0.65rem;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- padding: 0.1rem 0.4rem;
- border-radius: 8px;
-}
-
-.step-log-feed-status.connected {
- background: #d1fae5;
- color: #059669;
-}
-
-.step-log-feed-status.disconnected {
- background: #f3f4f6;
- color: #9ca3af;
-}
-
-.step-log-feed-interrupt-btn {
- background: #fef2f2;
- border: 1px solid #fecaca;
- color: #dc2626;
- font-size: 0.72rem;
- font-weight: 600;
- padding: 0.2rem 0.5rem;
- border-radius: 5px;
- cursor: pointer;
- transition: background 0.15s, border-color 0.15s;
-}
-
-.step-log-feed-interrupt-btn:hover {
- background: #fee2e2;
- border-color: #f87171;
-}
-
-.step-log-feed-collapse-btn {
- background: none;
- border: 1px solid #e5e7eb;
- color: #6b7280;
- font-size: 0.75rem;
- width: 22px;
- height: 22px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 4px;
- cursor: pointer;
- transition: all 0.15s;
- padding: 0;
-}
-
-.step-log-feed-collapse-btn:hover {
- background: #f3f4f6;
- color: #1f2937;
- border-color: #d1d5db;
-}
-
-/* Log content area */
-.step-log-feed-content {
- max-height: 280px;
- overflow-y: auto;
- background: #1a1b26;
- border-radius: 6px;
- padding: 0.5rem;
- font-family: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, Consolas, monospace;
- font-size: 0.75rem;
- line-height: 1.5;
-}
-
-.step-log-feed-empty {
- color: #6b7280;
- font-style: italic;
- padding: 1rem;
- text-align: center;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-}
-
-.step-log-feed-error {
- color: #f87171;
- padding: 0.25rem;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
- font-size: 0.78rem;
-}
-
-/* Log entries */
-.step-log-entry {
- display: flex;
- gap: 0.5rem;
- padding: 0.1rem 0.25rem;
- border-radius: 2px;
-}
-
-.step-log-entry:hover {
- background: rgba(255, 255, 255, 0.03);
-}
-
-.step-log-entry-time {
- color: #565f89;
- white-space: nowrap;
- flex-shrink: 0;
- min-width: 5.5em;
-}
-
-.step-log-entry-content {
- color: #a9b1d6;
- word-break: break-word;
- white-space: pre-wrap;
-}
-
-.step-log-entry--stderr .step-log-entry-content {
- color: #f7768e;
-}
-
-.step-log-entry--system .step-log-entry-content {
- color: #7aa2f7;
- font-style: italic;
-}
-
-.step-log-entry--user .step-log-entry-content {
- color: #9ece6a;
-}
-
-.step-log-entry--user::before {
- content: '> ';
- color: #9ece6a;
-}
-
-/* Message input */
-.step-log-feed-input {
- display: flex;
- gap: 0.35rem;
- margin-top: 0.4rem;
-}
-
-.step-log-feed-input-field {
- flex: 1;
- background: #f9fafb;
- border: 1px solid #e5e7eb;
- border-radius: 5px;
- padding: 0.35rem 0.6rem;
- font-size: 0.78rem;
- color: #1f2937;
- outline: none;
- transition: border-color 0.15s;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-}
-
-.step-log-feed-input-field:focus {
- border-color: #93c5fd;
- box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
-}
-
-.step-log-feed-input-field:disabled {
- opacity: 0.5;
-}
-
-.step-log-feed-send-btn {
- background: #3b82f6;
- border: none;
- color: #ffffff;
- font-size: 0.82rem;
- padding: 0.35rem 0.65rem;
- border-radius: 5px;
- cursor: pointer;
- transition: background 0.15s;
- white-space: nowrap;
-}
-
-.step-log-feed-send-btn:hover:not(:disabled) {
- background: #2563eb;
-}
-
-.step-log-feed-send-btn:disabled {
- background: #93c5fd;
- cursor: not-allowed;
-}
-
-/* ---- Responsive ---- */
-@media (max-width: 640px) {
- .steps-diagram {
- padding: 0.75rem;
- }
-
- .steps-diagram-card-wrapper {
- flex: 1 1 100%;
- max-width: 100%;
- }
-
- .steps-diagram-card-wrapper {
- flex: 1 1 100%;
- max-width: 100%;
- }
-
- .step-log-feed-content {
- max-height: 200px;
- }
-}
diff --git a/frontend/src/components/document/nodes/StepsDiagramComponent.tsx b/frontend/src/components/document/nodes/StepsDiagramComponent.tsx
index 53f860e..ac1cb83 100644
--- a/frontend/src/components/document/nodes/StepsDiagramComponent.tsx
+++ b/frontend/src/components/document/nodes/StepsDiagramComponent.tsx
@@ -70,32 +70,7 @@ function StepCard({ step, isExpanded, onToggleExpand, onCollapse }: StepCardProp
<span className="steps-diagram-card-time">
Completed {formatTime(step.completedAt)}
</span>
- {hasTask && (
- <button
- className={`steps-diagram-card-expand-btn ${expanded ? 'steps-diagram-card-expand-btn--open' : ''}`}
- onClick={() => setExpanded((v) => !v)}
- title={expanded ? 'Collapse log feed' : 'Expand log feed'}
- >
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
- <polyline points="6 9 12 15 18 9" />
- </svg>
- </button>
- )}
- </div>
- {step.description && (
- <p className="steps-diagram-card-desc">{step.description}</p>
)}
- <div className="steps-diagram-card-footer">
- <span className="steps-diagram-card-index">#{step.orderIndex}</span>
- {status === 'running' && (
- <span className="steps-diagram-card-progress">In progress...</span>
- )}
- {status === 'completed' && step.completedAt && (
- <span className="steps-diagram-card-time">
- Completed {formatTime(step.completedAt)}
- </span>
- )}
- </div>
</div>
{/* Expandable log feed */}
@@ -120,18 +95,6 @@ export function StepsDiagramComponent({ directiveId, onExpandContract }: StepsDi
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const prevStepCountRef = useRef(0);
- const toggleStep = useCallback((stepId: string) => {
- setExpandedSteps((prev) => {
- const next = new Set(prev);
- if (next.has(stepId)) {
- next.delete(stepId);
- } else {
- next.add(stepId);
- }
- return next;
- });
- }, []);
-
const fetchSteps = useCallback(async () => {
try {
const data: DirectiveWithSteps = await getDirective(directiveId);
diff --git a/frontend/src/components/document/nodes/StepsDiagramNode.tsx b/frontend/src/components/document/nodes/StepsDiagramNode.tsx
deleted file mode 100644
index 8b37f52..0000000
--- a/frontend/src/components/document/nodes/StepsDiagramNode.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import {
- DecoratorNode,
- DOMExportOutput,
- LexicalNode,
- NodeKey,
- SerializedLexicalNode,
- Spread,
-} from 'lexical';
-import React from 'react';
-import { StepsDiagramComponent } from './StepsDiagramComponent';
-
-export type SerializedStepsDiagramNode = Spread<
- {
- directiveId: string;
- },
- SerializedLexicalNode
->;
-
-export class StepsDiagramNode extends DecoratorNode<JSX.Element> {
- __directiveId: string;
-
- static getType(): string {
- return 'steps-diagram';
- }
-
- static clone(node: StepsDiagramNode): StepsDiagramNode {
- return new StepsDiagramNode(node.__directiveId, node.__key);
- }
-
- constructor(directiveId: string, key?: NodeKey) {
- super(key);
- this.__directiveId = directiveId;
- }
-
- createDOM(): HTMLElement {
- const div = document.createElement('div');
- div.className = 'steps-diagram-block';
- return div;
- }
-
- updateDOM(): boolean {
- return false;
- }
-
- decorate(): JSX.Element {
- return <StepsDiagramComponent directiveId={this.__directiveId} />;
- }
-
- exportJSON(): SerializedStepsDiagramNode {
- return {
- ...super.exportJSON(),
- type: 'steps-diagram',
- directiveId: this.__directiveId,
- version: 1,
- };
- }
-
- static importJSON(serializedNode: SerializedStepsDiagramNode): StepsDiagramNode {
- return $createStepsDiagramNode(serializedNode.directiveId);
- }
-
- isInline(): boolean {
- return false;
- }
-
- canInsertTextBefore(): boolean {
- return false;
- }
-
- canInsertTextAfter(): boolean {
- return false;
- }
-
- exportDOM(): DOMExportOutput {
- const element = document.createElement('div');
- element.className = 'steps-diagram-block';
- element.setAttribute('data-directive-id', this.__directiveId);
- element.textContent = '[Steps Diagram]';
- return { element };
- }
-}
-
-export function $createStepsDiagramNode(directiveId: string): StepsDiagramNode {
- return new StepsDiagramNode(directiveId);
-}
-
-export function $isStepsDiagramNode(
- node: LexicalNode | null | undefined,
-): node is StepsDiagramNode {
- return node instanceof StepsDiagramNode;
-}