summaryrefslogtreecommitdiff
path: root/frontend/src/components/document/nodes
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components/document/nodes')
-rw-r--r--frontend/src/components/document/nodes/StepsDiagram.css360
-rw-r--r--frontend/src/components/document/nodes/StepsDiagramComponent.tsx180
-rw-r--r--frontend/src/components/document/nodes/StepsDiagramNode.tsx91
3 files changed, 631 insertions, 0 deletions
diff --git a/frontend/src/components/document/nodes/StepsDiagram.css b/frontend/src/components/document/nodes/StepsDiagram.css
new file mode 100644
index 0000000..f3e9305
--- /dev/null
+++ b/frontend/src/components/document/nodes/StepsDiagram.css
@@ -0,0 +1,360 @@
+/* ============================================
+ 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;
+}
+
+/* ---- 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 ---- */
+.steps-diagram-card {
+ flex: 1 1 180px;
+ max-width: 280px;
+ 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;
+ animation: stepCardAppear 0.35s ease-out both;
+}
+
+@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-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ margin-bottom: 0.3rem;
+}
+
+.steps-diagram-card-name {
+ font-weight: 600;
+ font-size: 0.85rem;
+ color: #1f2937;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1;
+}
+
+.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-progress {
+ color: #d97706;
+ font-style: italic;
+}
+
+.steps-diagram-card-time {
+ color: #6b7280;
+}
+
+/* ---- 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;
+}
+
+/* ---- 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;
+}
+
+/* ---- Responsive ---- */
+@media (max-width: 640px) {
+ .steps-diagram {
+ padding: 0.75rem;
+ }
+
+ .steps-diagram-card {
+ flex: 1 1 100%;
+ max-width: 100%;
+ }
+}
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>
+ );
+}
diff --git a/frontend/src/components/document/nodes/StepsDiagramNode.tsx b/frontend/src/components/document/nodes/StepsDiagramNode.tsx
new file mode 100644
index 0000000..8b37f52
--- /dev/null
+++ b/frontend/src/components/document/nodes/StepsDiagramNode.tsx
@@ -0,0 +1,91 @@
+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;
+}