Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 77 additions & 3 deletions packages/factory/src/client/components/FlowDiagram/FlowDiagram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import {
useEdgesState,
useNodesState,
} from '@xyflow/react';
import React, { useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

import type { CanonicalRunStatus } from '../../../shared/types/canonical.js';
import { DispatchEdge } from './edges/DispatchEdge.js';
import { ReferenceEdge } from './edges/ReferenceEdge.js';
import { ReturnEdge } from './edges/ReturnEdge.js';
import { SpineEdge } from './edges/SpineEdge.js';
import { createFlowConfig } from './mappers/run-to-flow.js';
import { createFlowConfig, deriveReviewerKey } from './mappers/run-to-flow.js';
import { CoderShadowNode } from './nodes/CoderShadowNode.js';
import { OrchestratorNode } from './nodes/OrchestratorNode.js';
import { PhaseAgentNode } from './nodes/PhaseAgentNode.js';
Expand All @@ -40,14 +41,31 @@ interface FlowDiagramProps {
status: CanonicalRunStatus;
}

const STAGGER_DELAY_MS = 150;

function FlowDiagramInner({ status }: FlowDiagramProps): React.JSX.Element {
const config = useMemo(() => createFlowConfig(status), [status]);
// Edge collapse state: tracks which source-target pairs are expanded
const [expandedEdgePairs, setExpandedEdgePairs] = useState<Set<string>>(() => new Set());
const prevReviewerKeyRef = useRef('');
const prevReviewerCountRef = useRef(0);

// Reset expanded pairs when reviewer set changes
const reviewerKey = deriveReviewerKey(status);
if (reviewerKey !== prevReviewerKeyRef.current) {
prevReviewerKeyRef.current = reviewerKey;
if (expandedEdgePairs.size > 0) {
setExpandedEdgePairs(new Set());
}
}

const config = useMemo(() => createFlowConfig(status, expandedEdgePairs), [status, expandedEdgePairs]);

const edgeTypes = useMemo(
() => ({
dispatch: DispatchEdge,
return: ReturnEdge,
spine: SpineEdge,
reference: ReferenceEdge,
}),
[],
);
Expand All @@ -56,10 +74,65 @@ function FlowDiagramInner({ status }: FlowDiagramProps): React.JSX.Element {
const [edges, setEdges, onEdgesChange] = useEdgesState(config.edges);

useEffect(() => {
const reviewerNodes = config.nodes.filter((n) => n.type === 'reviewer');
const nonReviewerNodes = config.nodes.filter((n) => n.type !== 'reviewer');
const currentReviewerCount = reviewerNodes.length;
const isNewReviewPhase = prevReviewerCountRef.current === 0 && currentReviewerCount > 0;

if (isNewReviewPhase) {
// Stagger reviewer nodes one at a time
setNodes(nonReviewerNodes);

const timerIds: ReturnType<typeof setTimeout>[] = [];
for (let i = 0; i < reviewerNodes.length; i++) {
const timerId = setTimeout(
() => {
const reviewerNode = reviewerNodes[i];
if (reviewerNode === undefined) return;

const isFinal = i === reviewerNodes.length - 1;
setNodes((prev) => [...prev, reviewerNode]);

if (isFinal) {
// Set edges only when all reviewer nodes are present
setEdges(config.edges);
}
},
(i + 1) * STAGGER_DELAY_MS,
);
timerIds.push(timerId);
}

prevReviewerCountRef.current = currentReviewerCount;

return () => {
for (const id of timerIds) {
clearTimeout(id);
}
};
}

setNodes(config.nodes);
setEdges(config.edges);
prevReviewerCountRef.current = currentReviewerCount;

return;
}, [config, setNodes, setEdges]);

const handleEdgeClick = useCallback(
(_event: React.MouseEvent, edge: { id: string; source: string; target: string }) => {
if (edge.id.startsWith('collapsed-')) {
const pairKey = `${edge.source}|${edge.target}`;
setExpandedEdgePairs((prev) => {
const next = new Set(prev);
next.add(pairKey);
return next;
});
}
},
[],
);

return (
<div className="flow-diagram">
<ReactFlow
Expand All @@ -69,6 +142,7 @@ function FlowDiagramInner({ status }: FlowDiagramProps): React.JSX.Element {
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onEdgeClick={handleEdgeClick}
fitView
>
<Background variant={BackgroundVariant.Dots} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function DispatchEdge({
targetY,
sourcePosition,
targetPosition,
offset: data?.offset ?? 0,
});

const isPending = data?.status === 'pending';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { Edge, EdgeProps } from '@xyflow/react';
import { EdgeLabelRenderer, getSmoothStepPath } from '@xyflow/react';
import React from 'react';

import type { ReferenceEdgeData } from '../mappers/run-to-flow.js';

type ReferenceEdgeType = Edge<ReferenceEdgeData, 'reference'>;

const STROKE_COLOR = '#c4c8d0';

export function ReferenceEdge({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
}: EdgeProps<ReferenceEdgeType>): React.JSX.Element {
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
});

return (
<>
<path
d={edgePath}
fill="none"
stroke={STROKE_COLOR}
strokeWidth={1}
strokeDasharray="4 3"
style={{ pointerEvents: 'none' }}
/>
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${String(labelX)}px,${String(labelY)}px)`,
pointerEvents: 'all',
fontStyle: 'italic',
fontSize: 9,
color: '#656d76',
background: 'rgba(250,251,252,0.85)',
padding: '1px 4px',
borderRadius: 3,
}}
className="nodrag nopan"
>
{data?.label ?? 'same agent'}
</div>
</EdgeLabelRenderer>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function ReturnEdge({
targetY,
sourcePosition,
targetPosition,
offset: data?.offset ?? 0,
});

const isPending = data?.status === 'pending';
Expand Down
Loading