diff options
Diffstat (limited to 'frontend/src/components/document')
22 files changed, 3 insertions, 3677 deletions
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">📦</span> - <span className="contract-block-name">{contractName}</span> - </div> - <div className="contract-block-error-msg">Unable to load: {error}</div> - </div> - ); - } - - const phase = contract?.phase?.toLowerCase() || 'unknown'; - const status = contract?.status?.toLowerCase() || 'unknown'; - const phaseColor = PHASE_COLORS[phase] || '#6b7280'; - const statusColor = STATUS_COLORS[status] || '#6b7280'; - - return ( - <div className="contract-block" contentEditable={false}> - <div className="contract-block-header"> - <span className="contract-block-icon">📦</span> - <span className="contract-block-name">{contract?.name || contractName}</span> - <span - className="contract-block-phase-badge" - style={{ backgroundColor: phaseColor + '20', color: phaseColor }} - > - {phase} - </span> - <span - className="contract-block-status-dot" - style={{ backgroundColor: statusColor }} - title={status} - /> - </div> - {contract?.contract_type && ( - <div className="contract-block-meta"> - <span className="contract-block-type">{contract.contract_type}</span> - </div> - )} - </div> - ); -} diff --git a/frontend/src/components/document/nodes/ContractBlockNode.tsx b/frontend/src/components/document/nodes/ContractBlockNode.tsx 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"> - × - </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" > ⏹ 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; -} |
