oxios 1.5.2

Oxios Agent OS — Agent Operating System powered by oxi-sdk
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import ReactFlow, {
  Background,
  BackgroundVariant,
  Controls,
  type Edge,
  MiniMap,
  type Node,
  type NodeMouseHandler,
  type ReactFlowInstance,
  type ReactFlowProps,
  ReactFlowProvider,
} from 'reactflow'
import 'reactflow/dist/style.css'
import { Network } from 'lucide-react'
import { EmptyState } from '@/components/shared/empty-state'
import { ErrorState } from '@/components/shared/error-state'
import { LoadingCards } from '@/components/shared/loading'
import { statusColor } from '@/components/shared/status-palette'
import type { TopologyEdge, TopologyNode } from '@/types/a2a'
import { AgentNode, type AgentNodeData } from './agent-node'

interface Props {
  nodes: TopologyNode[]
  edges: TopologyEdge[]
  isLoading?: boolean
  isError?: boolean
  onRetry?: () => void
  onNodeSelect?: (nodeId: string) => void
  selectedNodeId?: string | null
  /** Optional className for the outer wrapper. */
  className?: string
}

// reactflow v11.11.4 chosen for React 19 compat (v12 / @xyflow/react not yet validated).
// See RFC-T1-A §6.
const nodeTypes = { agent: AgentNode }

/**
 * Map message type to an OKLCH edge color via the `--color-message-*`
 * CSS variables defined in `index.css`. Inline styles accept `var(...)`
 * directly, so this stays in sync with the design system's OKLCH tokens
 * (no raw hex, no Tailwind-class dynamic composition needed).
 */
function edgeColor(kind: string): string {
  switch (kind) {
    case 'task_delegation':
      return 'var(--color-message-task)'
    case 'status_update':
      return 'var(--color-message-status)'
    case 'result_sharing':
      return 'var(--color-message-result)'
    case 'capability_query':
      return 'var(--color-message-query)'
    case 'handshake':
      return 'var(--color-message-handshake)'
    default:
      return 'var(--color-message-default)'
  }
}

/**
 * Build a deterministic initial position for a node so that
 * re-renders don't move things around. We use a grid layout.
 */
function initialPosition(index: number, total: number): { x: number; y: number } {
  if (total === 0) return { x: 0, y: 0 }
  const cols = Math.max(1, Math.ceil(Math.sqrt(total)))
  const row = Math.floor(index / cols)
  const col = index % cols
  return { x: 80 + col * 260, y: 60 + row * 160 }
}

/**
 * Interactive A2A topology graph.
 *
 * Wraps React Flow with a custom agent node, controls, minimap and
 * dotted background. Empty/loading/error states are surfaced via the
 * shared components.
 */
export function InteractiveTopology({
  nodes,
  edges,
  isLoading,
  isError,
  onRetry,
  onNodeSelect,
  selectedNodeId,
  className,
}: Props) {
  const { t } = useTranslation()
  const wrapperRef = useRef<HTMLDivElement>(null)
  const rfInstanceRef = useRef<ReactFlowInstance | null>(null)

  const handleNodeClick: NodeMouseHandler = useCallback(
    (_event, node) => {
      onNodeSelect?.(node.id)
    },
    [onNodeSelect],
  )

  const flowNodes: Node<AgentNodeData>[] = useMemo(() => {
    return nodes.map((n, i) => {
      const existing = rfInstanceRef.current?.getNode(n.id)
      return {
        id: n.id,
        type: 'agent',
        position: existing?.position ?? initialPosition(i, nodes.length),
        data: {
          ...n,
          selected: n.id === selectedNodeId,
          onSelect: (id: string) => onNodeSelect?.(id),
        },
      }
    })
  }, [nodes, selectedNodeId, onNodeSelect])

  const flowEdges: Edge[] = useMemo(
    () =>
      edges.map((e) => {
        const isSelected =
          selectedNodeId != null && (e.from === selectedNodeId || e.to === selectedNodeId)
        return {
          id: `${e.from}->${e.to}`,
          source: e.from,
          target: e.to,
          animated: e.message_count_5m > 0,
          label: e.message_count_5m > 1 ? String(e.message_count_5m) : undefined,
          style: {
            stroke: edgeColor(e.last_kind),
            strokeWidth: isSelected ? 2.5 : 1.5,
            opacity: selectedNodeId ? (isSelected ? 1 : 0.25) : 0.75,
          },
        }
      }),
    [edges, selectedNodeId],
  )

  // Fit view whenever the node count changes (e.g. first load).
  useEffect(() => {
    if (rfInstanceRef.current && flowNodes.length > 0) {
      rfInstanceRef.current.fitView({ padding: 0.2, duration: 200 })
    }
  }, [flowNodes.length])

  // Keyboard navigation: focus the first node when the graph mounts.
  const handleInit: ReactFlowProps['onInit'] = useCallback((instance) => {
    rfInstanceRef.current = instance
  }, [])

  if (isLoading) {
    return <LoadingCards count={3} />
  }
  if (isError) {
    return (
      <ErrorState
        title={t('a2a.topologyErrorTitle')}
        message={t('a2a.topologyErrorMessage')}
        onRetry={onRetry}
      />
    )
  }
  if (nodes.length === 0) {
    return (
      <EmptyState
        icon={<Network className="h-10 w-10" aria-hidden="true" />}
        title={t('a2a.noTopology')}
        description={t('a2a.noTopologyDescription')}
      />
    )
  }

  return (
    <div
      ref={wrapperRef}
      className={`h-[520px] w-full rounded-xl border bg-background ${className ?? ''}`}
      data-testid="a2a-topology-canvas"
    >
      <ReactFlowProvider>
        <ReactFlow
          nodes={flowNodes}
          edges={flowEdges}
          nodeTypes={nodeTypes}
          fitView
          fitViewOptions={{ padding: 0.2 }}
          onInit={handleInit}
          onNodeClick={handleNodeClick}
          proOptions={{ hideAttribution: true }}
          minZoom={0.3}
          maxZoom={2}
          zoomOnPinch
          panOnDrag
          zoomOnScroll
          preventScrolling
          nodesDraggable
          nodesConnectable={false}
          edgesFocusable
          elementsSelectable
        >
          <Background variant={BackgroundVariant.Dots} gap={16} size={1} />
          <Controls
            position="bottom-right"
            showInteractive={false}
            aria-label={t('a2a.graphControls')}
          />
          <MiniMap
            pannable
            zoomable
            nodeStrokeColor={(n) => statusColor(String(n.data?.status ?? 'default'))}
            nodeColor={(n) => statusColor(String(n.data?.status ?? 'default'))}
            maskColor="rgba(0,0,0,0.08)"
            aria-label={t('a2a.graphMinimap')}
          />
        </ReactFlow>
      </ReactFlowProvider>
    </div>
  )
}