"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Background, BackgroundVariant, Controls, PanOnScrollMode, ReactFlow, useNodesInitialized, useNodesState, useReactFlow, type Node, type OnMove, type Rect } from "@xyflow/react"; import type { CanvasItem, ExecutionViewV8, OpenTarget } from "@/lib/types"; import { buildHorizontalLayout, type CanvasNodeData } from "@/lib/layout"; import { StoryCard } from "@/components/flow/StoryCard"; import { RoundSummaryCard } from "@/components/flow/RoundSummaryCard"; import { RunResultCard } from "@/components/flow/RunResultCard"; import { RoundFrame } from "@/components/flow/RoundFrame"; import { CandidateCard } from "@/components/flow/CandidateCard"; import { AgentRegion } from "@/components/flow/AgentRegion"; import { BranchFrame } from "@/components/flow/BranchFrame"; import { RetrievalStageFrame } from "@/components/flow/RetrievalStageFrame"; import { DirectToolGroupCard } from "@/components/flow/DirectToolGroupCard"; import { RetrievalAgentCard } from "@/components/flow/RetrievalAgentCard"; import { AgentDecisionCard } from "@/components/decision/AgentDecisionCard"; const nodeTypes = { storyCard: StoryCard, agentDecision: AgentDecisionCard, candidateCard: CandidateCard, roundSummary: RoundSummaryCard, finalResult: RunResultCard, roundFrame: RoundFrame, agentRegion: AgentRegion, branchFrame: BranchFrame, retrievalStageFrame: RetrievalStageFrame, directToolGroup: DirectToolGroupCard, retrievalAgent: RetrievalAgentCard }; const MIN_CANVAS_ZOOM = .2; const INITIAL_VIEWPORT = { x: 18, y: 94, zoom: MIN_CANVAS_ZOOM }; export function centeredOverviewViewport(bounds: Pick, canvasWidth: number) { return { x: canvasWidth / 2 - (bounds.x + bounds.width / 2) * MIN_CANVAS_ZOOM, y: INITIAL_VIEWPORT.y, zoom: MIN_CANVAS_ZOOM, }; } interface Props { view: ExecutionViewV8; expandedRounds: Set; expandedBranches: Set; expandedRetrievalAgents: Set; onToggleRound: (round: number) => void; onToggleBranch: (round: number, branchId: number) => void; onToggleRetrievalAgent: (agentId: string) => void; onOpen: (item: CanvasItem, target: OpenTarget) => void; onPrompt: (promptRef: string) => void; selectedId?: string; focusRound?: { roundIndex: number; token: number }; canvasFullscreen?: boolean; onExitFullscreen?: () => void; } export function FlowCanvas(props: Props) { const { getNode, getNodesBounds, setCenter, setViewport } = useReactFlow(); const nodesInitialized = useNodesInitialized(); const shellRef = useRef(null); const previousBuild = useRef(undefined); const positionBuild = useRef(undefined); const manualPositions = useRef(new Map()); const [detailLevel, setDetailLevel] = useState<"full" | "compact" | "overview">("overview"); const layout = useMemo(() => buildHorizontalLayout(props.view, { buildId: String(props.view.header.id), expandedRounds: props.expandedRounds, expandedBranches: props.expandedBranches, expandedRetrievalAgents: props.expandedRetrievalAgents, detailLevel, onOpen: props.onOpen, onPrompt: props.onPrompt, onToggleRound: props.onToggleRound, onToggleBranch: props.onToggleBranch, onToggleRetrievalAgent: props.onToggleRetrievalAgent }), [props.view, props.expandedRounds, props.expandedBranches, props.expandedRetrievalAgents, detailLevel, props.onOpen, props.onPrompt, props.onToggleRound, props.onToggleBranch, props.onToggleRetrievalAgent]); const [nodes, setNodes, onNodesChange] = useNodesState>([]); const handleMove: OnMove = useCallback((_, viewport) => { setDetailLevel((current) => { if (current === "full") return viewport.zoom < .74 ? "compact" : "full"; if (current === "compact") { if (viewport.zoom >= .82) return "full"; return viewport.zoom < .36 ? "overview" : "compact"; } return viewport.zoom > .42 ? "compact" : "overview"; }); }, []); useEffect(() => { const buildId = String(props.view.header.id); if (positionBuild.current !== buildId) { positionBuild.current = buildId; manualPositions.current.clear(); } setNodes(layout.nodes.map((node) => { const saved = manualPositions.current.get(node.id); return saved && saved.parentId === node.parentId ? { ...node, position: saved.position } : node; })); }, [layout.nodes, props.view.header.id, setNodes]); const rememberNodePosition = useCallback((_: unknown, node: Node) => { if (!node.draggable) return; manualPositions.current.set(node.id, { position: node.position, parentId: node.parentId }); }, []); useEffect(() => { const buildId = String(props.view.header.id); if (!nodesInitialized || previousBuild.current === buildId) return; previousBuild.current = buildId; const frame = requestAnimationFrame(() => { const topLevelNodes = layout.nodes.filter((node) => !node.parentId); const canvasWidth = shellRef.current?.clientWidth || 0; const viewport = topLevelNodes.length && canvasWidth ? centeredOverviewViewport(getNodesBounds(topLevelNodes), canvasWidth) : INITIAL_VIEWPORT; void setViewport(viewport, { duration: 0 }); }); return () => cancelAnimationFrame(frame); }, [props.view.header.id, getNodesBounds, layout.nodes, nodesInitialized, setViewport]); useEffect(() => { if (!props.focusRound) return; const frame = requestAnimationFrame(() => { const target = getNode(`frame:round:${props.focusRound!.roundIndex}`) || getNode(`round:${props.focusRound!.roundIndex}:summary`); if (target) { const isFrame = target.id.startsWith("frame:"); const width = isFrame ? Number(target.style?.width || 1980) : 320; const height = isFrame ? Number(target.style?.height || 720) : 360; void setCenter(target.position.x + width / 2, target.position.y + height / 2, { zoom: 1, duration: 220 }); } }); return () => cancelAnimationFrame(frame); }, [props.focusRound?.token, getNode, setCenter]); return
{props.canvasFullscreen ? : null} ({ ...node, selected: node.id === props.selectedId }))} edges={layout.edges} nodeTypes={nodeTypes} onNodesChange={onNodesChange} onNodeDrag={rememberNodePosition} onNodeDragStop={rememberNodePosition} defaultViewport={INITIAL_VIEWPORT} minZoom={MIN_CANVAS_ZOOM} maxZoom={1.5} onMove={handleMove} panOnScroll panOnScrollMode={PanOnScrollMode.Free} panOnScrollSpeed={.7} zoomOnScroll={false} zoomOnPinch nodesConnectable={false} elementsSelectable proOptions={{ hideAttribution: true }}>
; }