summaryrefslogtreecommitdiff
path: root/frontend/src/components/document/nodes/StepsDiagramComponent.tsx
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-04-28 00:18:40 +0100
committerGitHub <noreply@github.com>2026-04-28 00:18:40 +0100
commitc8b169da8cb7eae0957e0ab5e7370b071093a224 (patch)
treec3f9720a8acfe863ac0b65df9439abf9a941323a /frontend/src/components/document/nodes/StepsDiagramComponent.tsx
parent3679ceb3325033faa2f889ef3dfee5668ef7aeea (diff)
downloadsoryu-c8b169da8cb7eae0957e0ab5e7370b071093a224.tar.gz
soryu-c8b169da8cb7eae0957e0ab5e7370b071093a224.zip
feat: Document UI for directive orchestration with Lexical editor (#93)
* WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Save previous goal on update and include history in re-planning prompt * feat: soryu-co/soryu - makima: Install Lexical and create base document editor component * feat: soryu-co/soryu - makima: Create directive file system sidebar and document layout * feat: soryu-co/soryu - makima: Create custom Lexical step diagram block * feat: soryu-co/soryu - makima: Add context menu and goal auto-update integration * WIP: heartbeat checkpoint
Diffstat (limited to 'frontend/src/components/document/nodes/StepsDiagramComponent.tsx')
-rw-r--r--frontend/src/components/document/nodes/StepsDiagramComponent.tsx180
1 files changed, 180 insertions, 0 deletions
diff --git a/frontend/src/components/document/nodes/StepsDiagramComponent.tsx b/frontend/src/components/document/nodes/StepsDiagramComponent.tsx
new file mode 100644
index 0000000..606c0ab
--- /dev/null
+++ b/frontend/src/components/document/nodes/StepsDiagramComponent.tsx
@@ -0,0 +1,180 @@
+import React, { useEffect, useState, useRef, useCallback } from 'react';
+import { getDirective, DirectiveStep, DirectiveWithSteps } from '../../../services/directiveApi';
+import './StepsDiagram.css';
+
+interface StepsDiagramComponentProps {
+ directiveId: string;
+}
+
+type StepStatus = 'pending' | 'ready' | 'running' | 'completed' | 'failed' | 'skipped';
+
+const STATUS_LABELS: Record<string, string> = {
+ pending: 'Pending',
+ ready: 'Ready',
+ running: 'Running',
+ completed: 'Done',
+ failed: 'Failed',
+ skipped: 'Skipped',
+};
+
+function formatTime(dateStr: string): string {
+ if (!dateStr) return '';
+ const d = new Date(dateStr);
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+}
+
+function StepCard({ step }: { step: DirectiveStep }) {
+ const status = (step.status || 'pending').toLowerCase() as StepStatus;
+
+ return (
+ <div className={`steps-diagram-card steps-diagram-card--${status}`}>
+ <div className="steps-diagram-card-header">
+ <span className="steps-diagram-card-name">{step.name}</span>
+ <span className={`steps-diagram-status-badge steps-diagram-status-badge--${status}`}>
+ {STATUS_LABELS[status] || status}
+ </span>
+ </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>
+ );
+}
+
+export function StepsDiagramComponent({ directiveId }: StepsDiagramComponentProps) {
+ const [steps, setSteps] = useState<DirectiveStep[]>([]);
+ const [directiveStatus, setDirectiveStatus] = useState<string>('');
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
+ const prevStepCountRef = useRef(0);
+
+ const fetchSteps = useCallback(async () => {
+ try {
+ const data: DirectiveWithSteps = await getDirective(directiveId);
+ setSteps(data.steps || []);
+ setDirectiveStatus(data.status || '');
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load steps');
+ } finally {
+ setLoading(false);
+ }
+ }, [directiveId]);
+
+ useEffect(() => {
+ fetchSteps();
+ intervalRef.current = setInterval(fetchSteps, 5000);
+ return () => {
+ if (intervalRef.current) clearInterval(intervalRef.current);
+ };
+ }, [fetchSteps]);
+
+ // Track when new steps appear for animation
+ useEffect(() => {
+ prevStepCountRef.current = steps.length;
+ }, [steps.length]);
+
+ const completedCount = steps.filter(s => s.status?.toLowerCase() === 'completed').length;
+ const totalCount = steps.length;
+ const isActive = ['active', 'running', 'planning'].includes(directiveStatus.toLowerCase());
+ const isBuilding = isActive && steps.length === 0;
+ const isAddingSteps = isActive && steps.length > 0 && steps.length > prevStepCountRef.current;
+
+ // Group steps by orderIndex
+ const groupedSteps: Map<number, DirectiveStep[]> = new Map();
+ const sortedSteps = [...steps].sort((a, b) => a.orderIndex - b.orderIndex);
+ for (const step of sortedSteps) {
+ const idx = step.orderIndex;
+ if (!groupedSteps.has(idx)) groupedSteps.set(idx, []);
+ groupedSteps.get(idx)!.push(step);
+ }
+ const orderGroups = Array.from(groupedSteps.entries()).sort((a, b) => a[0] - b[0]);
+
+ if (loading) {
+ return (
+ <div className="steps-diagram" contentEditable={false}>
+ <div className="steps-diagram-header">
+ <span className="steps-diagram-header-title">Steps</span>
+ <span className="steps-diagram-header-author">Authored by Makima</span>
+ </div>
+ <div className="steps-diagram-loading">
+ <div className="steps-diagram-spinner" />
+ <span>Loading steps...</span>
+ </div>
+ </div>
+ );
+ }
+
+ if (error) {
+ return (
+ <div className="steps-diagram" contentEditable={false}>
+ <div className="steps-diagram-header">
+ <span className="steps-diagram-header-title">Steps</span>
+ <span className="steps-diagram-header-author">Authored by Makima</span>
+ </div>
+ <div className="steps-diagram-error">Failed to load steps: {error}</div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="steps-diagram" contentEditable={false}>
+ <div className="steps-diagram-header">
+ <div className="steps-diagram-header-left">
+ <span className="steps-diagram-header-title">Steps</span>
+ {totalCount > 0 && (
+ <span className="steps-diagram-header-count">
+ {completedCount}/{totalCount} completed
+ </span>
+ )}
+ </div>
+ <span className="steps-diagram-header-author">Authored by Makima</span>
+ </div>
+
+ {isBuilding && (
+ <div className="steps-diagram-planning">
+ <div className="steps-diagram-planning-dots">
+ <span /><span /><span />
+ </div>
+ <span>Makima is building the plan...</span>
+ </div>
+ )}
+
+ {totalCount === 0 && !isBuilding && (
+ <div className="steps-diagram-empty">No steps defined yet.</div>
+ )}
+
+ {totalCount > 0 && (
+ <div className="steps-diagram-dag">
+ {orderGroups.map(([orderIndex, groupSteps], groupIdx) => (
+ <React.Fragment key={orderIndex}>
+ {groupIdx > 0 && (
+ <div className="steps-diagram-arrow">
+ <div className="steps-diagram-arrow-line" />
+ <div className="steps-diagram-arrow-head" />
+ </div>
+ )}
+ <div className="steps-diagram-group">
+ {groupSteps.map((step) => (
+ <StepCard key={step.id} step={step} />
+ ))}
+ </div>
+ </React.Fragment>
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}