summaryrefslogblamecommitdiff
path: root/frontend/src/components/document/nodes/StepsDiagramComponent.tsx
blob: 53f860ee716916df15a07fa6641b58aa185c0699 (plain) (tree)
1
2
3
4
5
6
7
8

                                                                                                 
                                            



                                      
                                                   




                                                                                       
                    
                 

                         









                                                                          







                                                                                    
                                                                        

                                                                                   

          







                                                                                                                                        
                                                                    









                                                                                               
            
                                           










                                                                             













                                                                                                                                                             
          










                                                                               
            









                                                 



          
                                                                                                      



                                                                     
                                                                            


                                                                          











                                                      






                                                                       
                                                                                

















                                                                  


















                                                                  



                                                                                             














                                                                                     
                                                                            



                                                                                 
                                           








                                                             
                                                                            

                                                                                 
                                                                                    




            
                                                                                                                    

                                                   
                                                                            

                                                         
                                                     










                                                                               
                                                      



                                           
                                                                                 













                                                                    






                                                                








                             
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { getDirective, DirectiveStep, DirectiveWithSteps } from '../../../services/directiveApi';
import { StepLogFeed } from './StepLogFeed';
import './StepsDiagram.css';

interface StepsDiagramComponentProps {
  directiveId: string;
  onExpandContract?: (step: DirectiveStep) => void;
}

type StepStatus = 'pending' | 'ready' | 'running' | 'completed' | 'failed' | 'skipped';

const STATUS_LABELS: Record<string, string> = {
  pending: 'Queued',
  ready: 'Ready',
  running: 'Executing',
  completed: 'Fulfilled',
  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' });
}

interface StepCardProps {
  step: DirectiveStep;
  isExpanded: boolean;
  onToggleExpand: () => void;
  onCollapse: () => void;
}

function StepCard({ step, isExpanded, onToggleExpand, onCollapse }: StepCardProps) {
  const status = (step.status || 'pending').toLowerCase() as StepStatus;
  const hasTask = !!step.taskId || !!step.contractId;
  const canExpand = hasTask && ['running', 'completed', 'failed'].includes(status);

  return (
    <div className={`steps-diagram-card steps-diagram-card--${status} ${isExpanded ? 'steps-diagram-card--expanded' : ''}`}>
      <div
        className={`steps-diagram-card-header ${canExpand ? 'steps-diagram-card-header--clickable' : ''}`}
        onClick={canExpand ? onToggleExpand : undefined}
        role={canExpand ? 'button' : undefined}
        tabIndex={canExpand ? 0 : undefined}
        onKeyDown={canExpand ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggleExpand(); } } : undefined}
      >
        <span className="steps-diagram-card-name">{step.name}</span>
        <div className="steps-diagram-card-header-right">
          <span className={`steps-diagram-status-badge steps-diagram-status-badge--${status}`}>
            {STATUS_LABELS[status] || status}
          </span>
          {canExpand && (
            <span className={`steps-diagram-expand-icon ${isExpanded ? 'expanded' : ''}`}>
              &#x25B6;
            </span>
          )}
        </div>
      </div>
      {step.description && !isExpanded && (
        <p className="steps-diagram-card-desc">{step.description}</p>
      )}
      <div className="steps-diagram-card-footer">
        <span className="steps-diagram-card-index">#{step.orderIndex}</span>
        {status === 'running' && (
          <span className="steps-diagram-card-progress">In progress...</span>
        )}
        {status === 'completed' && step.completedAt && (
          <span className="steps-diagram-card-time">
            Completed {formatTime(step.completedAt)}
          </span>
          {hasTask && (
            <button
              className={`steps-diagram-card-expand-btn ${expanded ? 'steps-diagram-card-expand-btn--open' : ''}`}
              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 */}
      {isExpanded && hasTask && (
        <StepLogFeed
          taskId={step.taskId || step.contractId}
          stepName={step.name}
          stepStatus={status}
          onCollapse={onCollapse}
        />
      )}
    </div>
  );
}

export function StepsDiagramComponent({ directiveId, onExpandContract }: StepsDiagramComponentProps) {
  const [steps, setSteps] = useState<DirectiveStep[]>([]);
  const [directiveStatus, setDirectiveStatus] = useState<string>('');
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [expandedStepId, setExpandedStepId] = useState<string | null>(null);
  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);
      setSteps(data.steps || []);
      setDirectiveStatus(data.status || '');
      setError(null);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to load contracts');
    } 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]);

  // Keyboard shortcut: Escape to collapse expanded step
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if (e.key === 'Escape' && expandedStepId) {
        setExpandedStepId(null);
      }
    };
    document.addEventListener('keydown', handler);
    return () => document.removeEventListener('keydown', handler);
  }, [expandedStepId]);

  const toggleExpand = useCallback((stepId: string) => {
    setExpandedStepId(prev => prev === stepId ? null : stepId);
  }, []);

  const collapseExpanded = useCallback(() => {
    setExpandedStepId(null);
  }, []);

  const completedCount = steps.filter(s => s.status?.toLowerCase() === 'completed').length;
  const totalCount = steps.length;
  const isActive = ['active', 'running', 'planning'].includes(directiveStatus.toLowerCase());
  const isBuilding = isActive && steps.length === 0;

  // 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">Contract 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 contracts...</span>
        </div>
      </div>
    );
  }

  if (error) {
    return (
      <div className="steps-diagram" contentEditable={false}>
        <div className="steps-diagram-header">
          <span className="steps-diagram-header-title">Contract Steps</span>
          <span className="steps-diagram-header-author">Authored by Makima</span>
        </div>
        <div className="steps-diagram-error">Failed to load contracts: {error}</div>
      </div>
    );
  }

  return (
    <div className={`steps-diagram ${expandedStepId ? 'steps-diagram--has-expanded' : ''}`} contentEditable={false}>
      <div className="steps-diagram-header">
        <div className="steps-diagram-header-left">
          <span className="steps-diagram-header-title">Contract Steps</span>
          {totalCount > 0 && (
            <span className="steps-diagram-header-count">
              {completedCount}/{totalCount} fulfilled
            </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 drafting contracts...</span>
        </div>
      )}

      {totalCount === 0 && !isBuilding && (
        <div className="steps-diagram-empty">No contract 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}
                    isExpanded={expandedStepId === step.id}
                    onToggleExpand={() => toggleExpand(step.id)}
                    onCollapse={collapseExpanded}
                  />
                ))}
              </div>
            </React.Fragment>
          ))}
        </div>
      )}
    </div>
  );
}