import { MarkerType, type Edge, type Node } from "@xyflow/react"; import type { BranchStory, CanvasItem, DirectToolGroup, ExecutionViewV8, OpenTarget, RetrievalAgentRun, RetrievalStage, RoundStoryV8, StoryNode, } from "@/lib/types"; const STORY_W = 320; const RETRIEVAL_W = 340; const SUMMARY_W = 360; const ROUND_GAP = 104; const ROUND_HEADER = 88; const REGION_TOP = ROUND_HEADER + 16; const REGION_HEADER = 54; const REGION_PAD = 24; const REGION_GAP = 32; const CARD_GAP = 32; const BRANCH_HEADER = 66; const RETRIEVAL_HEADER = 72; const RETRIEVAL_ITEM_GAP = 18; const FLOW_CENTER_Y = 350; const FINAL_RESULT_H = 368; const PLANNING_W = STORY_W * 2 + CARD_GAP + REGION_PAD * 2; const IMPLEMENT_COMPACT_W = SUMMARY_W + REGION_PAD * 2; const SINGLE_REGION_W = STORY_W + REGION_PAD * 2; export type AgentRegionTone = "main" | "implementer" | "evaluator" | "outcome"; export type CanvasDetailLevel = "full" | "compact" | "overview"; export interface AgentRegionProjection { label: string; description: string; tone: AgentRegionTone } export interface CanvasNodeData extends Record { buildId?: string; item?: CanvasItem; round?: RoundStoryV8; region?: AgentRegionProjection; expanded?: boolean; onOpen: (item: CanvasItem, target: OpenTarget) => void; onPrompt?: (promptRef: string) => void; onToggleRound: (roundIndex: number) => void; onToggleBranch: (roundIndex: number, branchId: number) => void; onToggleRetrievalAgent: (agentId: string) => void; } export interface LayoutOptions { buildId?: string; expandedRounds: Set; expandedBranches: Set; expandedRetrievalAgents: Set; detailLevel?: CanvasDetailLevel; onOpen: CanvasNodeData["onOpen"]; onPrompt?: CanvasNodeData["onPrompt"]; onToggleRound: CanvasNodeData["onToggleRound"]; onToggleBranch: CanvasNodeData["onToggleBranch"]; onToggleRetrievalAgent: CanvasNodeData["onToggleRetrievalAgent"]; } export interface LayoutResult { nodes: Array>; edges: Edge[] } interface RoundMetrics { width: number; height: number; regionHeight: number; laneTops: number[]; laneHeights: number[]; contentCenterY: number; planningX: number; implementationX: number; implementationWidth: number; reviewX: number; convergenceX: number; evaluatorX: number; outcomeX: number; batchTops: number[]; batchHeights: Array<{ review: number; decision: number; group: number }>; } export function buildHorizontalLayout(view: ExecutionViewV8, options: LayoutOptions): LayoutResult { const nodes: Array> = []; const edges: Edge[] = []; const planning = view.planning ? storyItem(view.planning) : undefined; const objective = storyItem(view.objective); if (planning) nodes.push(canvasNode(planning, "storyCard", { x: 60, y: 210 }, options)); const objectiveX = planning ? 60 + STORY_W + ROUND_GAP : 60; nodes.push(canvasNode(objective, "storyCard", { x: objectiveX, y: 210 }, options)); if (planning) edges.push(flowEdge(planning.id, objective.id)); const roundX = objectiveX + STORY_W + ROUND_GAP; let previousRowBottom = 0; let previous = objective.id; let finalX = roundX; let finalY = 170; for (const [roundPosition, round] of [...view.rounds].sort((a, b) => a.roundIndex - b.roundIndex).entries()) { if (!options.expandedRounds.has(round.roundIndex)) { const item = roundSummaryItem(round); const rowHeight = collapsedRoundHeight(options.detailLevel ?? "full"); const rowY = roundPosition === 0 ? 148 : previousRowBottom + ROUND_GAP; nodes.push(canvasNode(item, "roundSummary", { x: roundX, y: rowY }, options)); edges.push(flowEdge(previous, item.id, { status: round.state })); previous = item.id; previousRowBottom = rowY + rowHeight; finalX = roundX + SUMMARY_W + ROUND_GAP; finalY = rowY + (rowHeight - FINAL_RESULT_H) / 2; continue; } const groupId = `frame:round:${round.roundIndex}`; const metrics = expandedRoundMetrics(round, options); const rowY = roundPosition === 0 ? Math.min(42, FLOW_CENTER_Y - metrics.contentCenterY) : previousRowBottom + ROUND_GAP; nodes.push({ id: groupId, type: "roundFrame", position: { x: roundX, y: rowY }, data: baseData(options, { round, expanded: true }), style: { width: metrics.width, height: metrics.height }, draggable: false, selectable: true, focusable: false, zIndex: -3, }); addAgentRegions(nodes, round, groupId, options, metrics); const result = addExpandedRound(nodes, edges, round, groupId, options, metrics); edges.push(flowEdge(previous, result.entry)); previous = result.exit; previousRowBottom = rowY + metrics.height; finalX = roundX + metrics.width + ROUND_GAP; finalY = rowY + metrics.contentCenterY - FINAL_RESULT_H / 2; } const finalItem = finalResultItem(view); nodes.push(canvasNode(finalItem, "finalResult", { x: finalX, y: finalY }, options)); edges.push(flowEdge(previous, finalItem.id, { status: view.finalResult.status })); return { nodes, edges }; } function expandedRoundMetrics(round: RoundStoryV8, options: LayoutOptions): RoundMetrics { const detailLevel = options.detailLevel ?? "full"; const density = densityFor(detailLevel); const expandedWidths = round.branches .filter((branch) => options.expandedBranches.has(branchKey(round.roundIndex, branch.branchId))) .map((branch) => branchFrameWidth(branch)); const implementationWidth = expandedWidths.length ? Math.max(...expandedWidths) + REGION_PAD * 2 : IMPLEMENT_COMPACT_W; const planningX = 32; const implementationX = planningX + PLANNING_W + REGION_GAP; const reviewX = implementationX + implementationWidth + REGION_GAP; const convergenceX = reviewX + SINGLE_REGION_W + REGION_GAP; const evaluatorX = convergenceX + SINGLE_REGION_W + REGION_GAP; const outcomeX = evaluatorX + SINGLE_REGION_W + REGION_GAP; const width = outcomeX + SINGLE_REGION_W + 32; const branches: Array = round.branches.length ? round.branches : [undefined]; const laneHeights = branches.map((branch) => { if (!branch) return 240; return options.expandedBranches.has(branchKey(round.roundIndex, branch.branchId)) ? branchFrameHeight(branch, detailLevel, options.expandedRetrievalAgents) : estimateBranchHeight(branch, detailLevel); }); const laneTops: number[] = []; const laneStart = REGION_TOP + REGION_HEADER + density.contentInset; laneHeights.reduce((top, height) => { laneTops.push(top); return top + height + density.laneGap; }, laneStart); const laneBottom = laneTops.at(-1)! + laneHeights.at(-1)!; const batchHeights = round.convergenceBatches.map((batch) => { const review = batch.review ? estimateMultipathReviewHeight(batch.review, detailLevel) : batch.missingReview ? estimateStoryHeight(batch.missingReview, detailLevel) : 244; const decision = batch.decision ? estimateMultipathDecisionHeight(batch.decision, detailLevel) : batch.missingDecision ? estimateStoryHeight(batch.missingDecision, detailLevel) : 244; return { review, decision, group: Math.max(review, decision) }; }); const batchStackHeight = batchHeights.reduce((sum, height) => sum + height.group, 0) + Math.max(0, batchHeights.length - 1) * density.laneGap; const contentHeight = Math.max( laneBottom - laneStart, batchStackHeight, estimateStoryHeight(round.goal, detailLevel), estimateStoryHeight(round.plan.node, detailLevel), estimateStoryHeight(round.overallEvaluation, detailLevel), estimateStoryHeight(round.result, detailLevel), ); const regionHeight = REGION_HEADER + density.contentInset + contentHeight + density.contentInset; const contentCenterY = laneStart + contentHeight / 2; const batchStart = laneStart + Math.max(0, (contentHeight - batchStackHeight) / 2); const batchTops: number[] = []; batchHeights.reduce((top, height) => { batchTops.push(top); return top + height.group + density.laneGap; }, batchStart); return { width, height: REGION_TOP + regionHeight + 34, regionHeight, laneTops, laneHeights, contentCenterY, planningX, implementationX, implementationWidth, reviewX, convergenceX, evaluatorX, outcomeX, batchTops, batchHeights }; } function addAgentRegions(nodes: Array>, round: RoundStoryV8, parentId: string, options: LayoutOptions, metrics: RoundMetrics) { const regions = [ { id: "planning", x: metrics.planningX, width: PLANNING_W, projection: { label: "主 Agent · 规划", description: "确定本轮目标,并拆成多路方案", tone: "main" as const } }, { id: "implementation", x: metrics.implementationX, width: metrics.implementationWidth, projection: { label: "实现 Agent · 多路方案", description: "每个方案独立取数、取舍并形成产出", tone: "implementer" as const } }, { id: "multipath-review", x: metrics.reviewX, width: SINGLE_REGION_W, projection: { label: "多方案评审 Agent", description: "比较候选脚本并给出评审建议", tone: "evaluator" as const } }, { id: "convergence", x: metrics.convergenceX, width: SINGLE_REGION_W, projection: { label: "主 Agent · 收敛", description: "结合评审处理各个候选方案", tone: "main" as const } }, { id: "evaluation", x: metrics.evaluatorX, width: SINGLE_REGION_W, projection: { label: "整体评审 Agent", description: "检查合并后的主脚本是否达标", tone: "evaluator" as const } }, { id: "outcome", x: metrics.outcomeX, width: SINGLE_REGION_W, projection: { label: "本轮结果", description: "汇总主脚本和领域信息产出", tone: "outcome" as const } }, ]; for (const region of regions) nodes.push({ id: `region:round:${round.roundIndex}:${region.id}`, type: "agentRegion", position: { x: region.x, y: REGION_TOP }, parentId, extent: "parent", data: baseData(options, { region: region.projection }), style: { width: region.width, height: metrics.regionHeight }, draggable: false, selectable: false, focusable: false, zIndex: -2, }); } function addExpandedRound(nodes: Array>, edges: Edge[], round: RoundStoryV8, parentId: string, options: LayoutOptions, metrics: RoundMetrics) { const detailLevel = options.detailLevel ?? "full"; const addStory = (story: StoryNode, x: number, y: number, branchId?: number, storyParentId = parentId) => { const item = storyItem(story, round.roundIndex, branchId); nodes.push(canvasNode(item, "storyCard", { x, y }, options, storyParentId)); return item.id; }; const goalId = addStory(round.goal, metrics.planningX + REGION_PAD, centeredRoundY(metrics, estimateStoryHeight(round.goal, detailLevel))); const planId = addStory(round.plan.node, metrics.planningX + REGION_PAD + STORY_W + CARD_GAP, centeredRoundY(metrics, estimateStoryHeight(round.plan.node, detailLevel))); edges.push(flowEdge(goalId, planId)); const branchEnds = new Map(); round.branches.forEach((branch, index) => { const laneTop = metrics.laneTops[index]; const laneHeight = metrics.laneHeights[index]; const expanded = options.expandedBranches.has(branchKey(round.roundIndex, branch.branchId)); if (!expanded) { const item = branchSummaryItem(branch, round.roundIndex); nodes.push(canvasNode(item, "candidateCard", { x: metrics.implementationX + REGION_PAD, y: laneTop + Math.max(0, (laneHeight - estimateBranchHeight(branch, detailLevel)) / 2) }, options, parentId)); edges.push(flowEdge(planId, item.id, { kind: "delegation" })); branchEnds.set(branch.branchId, item.id); return; } const frameId = `frame:round:${round.roundIndex}:branch:${branch.branchId}`; const frameHeight = branchFrameHeight(branch, detailLevel, options.expandedRetrievalAgents); const frameWidth = branchFrameWidth(branch); nodes.push({ id: frameId, type: "branchFrame", position: { x: metrics.implementationX + REGION_PAD, y: laneTop + Math.max(0, (laneHeight - frameHeight) / 2) }, parentId, extent: "parent", data: baseData(options, { item: branchSummaryItem(branch, round.roundIndex), expanded: true }), style: { width: frameWidth, height: frameHeight }, draggable: false, selectable: true, focusable: false, zIndex: 1, }); branchEnds.set(branch.branchId, addExpandedBranch(nodes, edges, round, branch, frameId, options, planId)); }); const batchIds: string[] = []; const covered = new Set(); round.convergenceBatches.forEach((batch, index) => { const batchTop = metrics.batchTops[index] ?? centeredRoundY(metrics, 244); const heights = metrics.batchHeights[index] ?? { review: 244, decision: 244, group: 244 }; const reviewY = batchTop + Math.max(0, (heights.group - heights.review) / 2); const decisionY = batchTop + Math.max(0, (heights.group - heights.decision) / 2); let reviewId: string; if (batch.review) { const item = multipathReviewItem(batch.review, round.roundIndex); nodes.push(canvasNode(item, "multipathReview", { x: metrics.reviewX + REGION_PAD, y: reviewY }, options, parentId)); reviewId = item.id; } else if (batch.missingReview) { reviewId = addStory(batch.missingReview, metrics.reviewX + REGION_PAD, reviewY); } else return; let decisionId: string; if (batch.decision) { const item = multipathDecisionItem(batch.decision, round.roundIndex); nodes.push(canvasNode(item, "multipathDecision", { x: metrics.convergenceX + REGION_PAD, y: decisionY }, options, parentId)); decisionId = item.id; } else if (batch.missingDecision) { decisionId = addStory(batch.missingDecision, metrics.convergenceX + REGION_PAD, decisionY); } else return; batchIds.push(decisionId); batch.branchIds.forEach((branchId) => { const source = branchEnds.get(branchId); if (source) { edges.push(flowEdge(source, reviewId, { kind: "delegation" })); covered.add(branchId); } }); edges.push(flowEdge(reviewId, decisionId)); }); const evaluationId = addStory(round.overallEvaluation, metrics.evaluatorX + REGION_PAD, centeredRoundY(metrics, estimateStoryHeight(round.overallEvaluation, detailLevel))); const resultId = addStory(round.result, metrics.outcomeX + REGION_PAD, centeredRoundY(metrics, estimateStoryHeight(round.result, detailLevel))); if (batchIds.length) batchIds.forEach((id) => edges.push(flowEdge(id, evaluationId, { kind: "delegation" }))); else edges.push(flowEdge(planId, evaluationId)); branchEnds.forEach((id, branchId) => { if (!covered.has(branchId) && batchIds.length) edges.push(flowEdge(id, evaluationId, { kind: "unassigned" })); }); edges.push(flowEdge(evaluationId, resultId, { status: round.state })); return { entry: goalId, exit: resultId }; } function addExpandedBranch(nodes: Array>, edges: Edge[], round: RoundStoryV8, branch: BranchStory, parentId: string, options: LayoutOptions, planId: string) { const detailLevel = options.detailLevel ?? "full"; const density = densityFor(detailLevel); const contentTop = BRANCH_HEADER + density.contentInset; const frameHeight = branchFrameHeight(branch, detailLevel, options.expandedRetrievalAgents); const contentHeight = frameHeight - contentTop - density.contentInset; const taskItem = storyItem(branch.task, round.roundIndex, branch.branchId); const taskY = contentTop + Math.max(0, (contentHeight - estimateStoryHeight(branch.task, detailLevel)) / 2); nodes.push(canvasNode(taskItem, "storyCard", { x: REGION_PAD, y: taskY }, options, parentId)); edges.push(flowEdge(planId, taskItem.id, { kind: "delegation" })); const retrievalX = REGION_PAD + STORY_W + CARD_GAP; const retrievalHeight = retrievalFrameHeight(branch.retrievalStage, options.expandedRetrievalAgents, detailLevel); const retrievalY = contentTop + Math.max(0, (contentHeight - retrievalHeight) / 2); const stageItem = retrievalStageItem(branch.retrievalStage, round.roundIndex, branch.branchId); nodes.push({ id: `frame:${stageItem.id}`, type: "retrievalStageFrame", position: { x: retrievalX, y: retrievalY }, parentId, extent: "parent", data: baseData(options, { item: stageItem }), style: { width: retrievalFrameWidth(branch.retrievalStage), height: retrievalHeight }, draggable: false, selectable: true, focusable: false, zIndex: 2, }); const retrievalEnds = addRetrievalOperations(nodes, edges, branch.retrievalStage, round.roundIndex, branch.branchId, `frame:${stageItem.id}`, options, taskItem.id); let cursorX = retrievalX + retrievalFrameWidth(branch.retrievalStage) + CARD_GAP; let previous = retrievalEnds.length ? retrievalEnds : [taskItem.id]; if (branch.dataDecisions.length) { const heights = branch.dataDecisions.map((item) => estimateStoryHeight(item.node, detailLevel)); const stackHeight = heights.reduce((sum, height) => sum + height, 0) + Math.max(0, heights.length - 1) * density.laneGap; let top = contentTop + Math.max(0, (contentHeight - stackHeight) / 2); const decisionIds: string[] = []; branch.dataDecisions.forEach((decision, index) => { const item = storyItem(decision.node, round.roundIndex, branch.branchId); nodes.push(canvasNode(item, "storyCard", { x: cursorX, y: top }, options, parentId)); decisionIds.push(item.id); top += heights[index] + density.laneGap; }); previous.forEach((source) => decisionIds.forEach((target) => edges.push(flowEdge(source, target)))); previous = decisionIds; cursorX += STORY_W + CARD_GAP; } const tail = [branch.output.node]; tail.forEach((story) => { const item = storyItem(story, round.roundIndex, branch.branchId); const y = contentTop + Math.max(0, (contentHeight - estimateStoryHeight(story, detailLevel)) / 2); nodes.push(canvasNode(item, "storyCard", { x: cursorX, y }, options, parentId)); previous.forEach((source) => edges.push(flowEdge(source, item.id))); previous = [item.id]; cursorX += STORY_W + CARD_GAP; }); return previous[0]; } function addRetrievalOperations(nodes: Array>, edges: Edge[], stage: RetrievalStage, roundIndex: number, branchId: number, parentId: string, options: LayoutOptions, sourceId: string) { const detailLevel = options.detailLevel ?? "full"; const operations = retrievalOperations(stage); if (!operations.length) return []; const waveIndexes = stage.waves.length ? stage.waves.map((wave) => wave.index) : [0]; const byWave = new Map>(); operations.forEach((operation) => { // No timestamp means no defensible ordering. Keep every operation in one // neutral column instead of inventing a left-to-right sequence. const wave = operation.waveIndex ?? 0; byWave.set(wave, [...(byWave.get(wave) || []), operation]); }); let previous = [sourceId]; let top = RETRIEVAL_HEADER + RETRIEVAL_ITEM_GAP; for (const waveIndex of [...new Set([...waveIndexes, ...byWave.keys()])].sort((a, b) => a - b)) { const waveItems = byWave.get(waveIndex) || []; if (!waveItems.length) continue; const heights = waveItems.map((item) => retrievalOperationHeight(item, options.expandedRetrievalAgents, detailLevel)); const ids: string[] = []; waveItems.forEach((operation, index) => { const item = operation.kind === "direct-tools" ? retrievalDirectItem(operation, roundIndex, branchId) : retrievalAgentItem(operation, roundIndex, branchId); nodes.push(canvasNode(item, operation.kind === "direct-tools" ? "directToolGroup" : "retrievalAgent", { x: 20, y: top }, options, parentId)); ids.push(item.id); top += heights[index] + RETRIEVAL_ITEM_GAP; }); previous.forEach((source) => ids.forEach((target) => edges.push(flowEdge(source, target, { kind: "delegation" })))); previous = ids; } return previous; } function retrievalOperations(stage: RetrievalStage): Array { return [...stage.directToolGroups, ...stage.agentRuns].sort((a, b) => { const wave = (a.waveIndex ?? 999) - (b.waveIndex ?? 999); return wave || String(a.startedAt || "").localeCompare(String(b.startedAt || "")) || a.id.localeCompare(b.id); }); } function branchFrameWidth(branch: BranchStory) { const decisionColumn = branch.dataDecisions.length ? STORY_W + CARD_GAP : 0; const tailColumns = 1; return REGION_PAD * 2 + STORY_W + CARD_GAP + retrievalFrameWidth(branch.retrievalStage) + CARD_GAP + decisionColumn + tailColumns * STORY_W + (tailColumns - 1) * CARD_GAP; } function retrievalFrameWidth(_stage: RetrievalStage) { return 40 + RETRIEVAL_W; } function retrievalFrameHeight(stage: RetrievalStage, expandedAgents: Set, detailLevel: CanvasDetailLevel) { const operations = retrievalOperations(stage); if (!operations.length) return detailLevel === "overview" ? 156 : 210; const contentHeight = operations.reduce((height, item) => height + retrievalOperationHeight(item, expandedAgents, detailLevel), 0) + Math.max(0, operations.length - 1) * RETRIEVAL_ITEM_GAP; return RETRIEVAL_HEADER + RETRIEVAL_ITEM_GAP + contentHeight + 24; } function retrievalOperationHeight(item: DirectToolGroup | RetrievalAgentRun, expandedAgents: Set, detailLevel: CanvasDetailLevel) { if (detailLevel === "overview") return item.kind === "direct-tools" ? 132 : 148; if (item.kind === "direct-tools") return detailLevel === "compact" ? 196 : 248; const attempts = expandedAgents.has(item.id) ? item.attempts.length : 0; if (detailLevel === "compact") return 220 + (attempts ? 44 + attempts * 40 : 0); return 292 + (attempts ? 52 + attempts * 48 : 0); } function branchFrameHeight(branch: BranchStory, detailLevel: CanvasDetailLevel, expandedAgents: Set) { const density = densityFor(detailLevel); const decisions = branch.dataDecisions.map((item) => estimateStoryHeight(item.node, detailLevel)); const decisionStack = decisions.reduce((sum, height) => sum + height, 0) + Math.max(0, decisions.length - 1) * density.laneGap; const storyHeights = [branch.task, branch.output.node].map((story) => estimateStoryHeight(story, detailLevel)); return BRANCH_HEADER + density.contentInset + Math.max(density.branchFloor, retrievalFrameHeight(branch.retrievalStage, expandedAgents, detailLevel), decisionStack, ...storyHeights) + density.contentInset; } export function branchFlow(branch: BranchStory): StoryNode[] { return [branch.task, ...branch.dataDecisions.map((item) => item.node), branch.output.node]; } export function storyItem(story: StoryNode, roundIndex?: number, branchId?: number): CanvasItem { return { ...story, itemType: "story", roundIndex, branchId }; } export function retrievalStageItem(stage: RetrievalStage, roundIndex: number, branchId: number): CanvasItem { return { itemType: "retrieval-stage", id: stage.id, title: "取数阶段", roundIndex, branchId, stage, detailRef: stage.detailRef }; } export function retrievalDirectItem(group: DirectToolGroup, roundIndex: number, branchId: number): CanvasItem { return { itemType: "retrieval-direct", id: group.id, title: "工具取数", roundIndex, branchId, group, detailRef: group.detailRef }; } export function retrievalAgentItem(run: RetrievalAgentRun, roundIndex: number, branchId: number): CanvasItem { return { itemType: "retrieval-agent", id: run.id, title: run.businessLabel, roundIndex, branchId, run, detailRef: run.detailRef }; } export function queryAttemptItem(attempt: RetrievalAgentRun["attempts"][number], roundIndex: number, branchId: number): CanvasItem { return { itemType: "query-attempt", id: attempt.id, title: attempt.queryLabel, roundIndex, branchId, attempt, detailRef: attempt.detailRef }; } export function retrievalActivityItem(activity: import("@/lib/types").RetrievalActivity, roundIndex: number, branchId: number): CanvasItem { return { itemType: "retrieval-activity", id: activity.id, title: activity.label, roundIndex, branchId, activity, detailRef: activity.detailRef }; } export function branchSummaryItem(branch: BranchStory, roundIndex: number): CanvasItem { return { itemType: "branch-summary", id: `round:${roundIndex}:branch:${branch.branchId}:summary`, title: branch.title, roundIndex, branchId: branch.branchId, branch, detailRef: branch.detailRef }; } export function multipathReviewItem(review: import("@/lib/types").MultipathReview, roundIndex: number): CanvasItem { return { itemType: "multipath-review", id: review.id, title: "多方案评审", roundIndex, review, detailRef: review.detailRef }; } export function multipathDecisionItem(decision: import("@/lib/types").MultipathDecisionBatch, roundIndex: number): CanvasItem { return { itemType: "multipath-decision", id: decision.id, title: "主 Agent 多路决策", roundIndex, decision, detailRef: decision.node.detailRef || decision.id }; } export function roundSummaryItem(round: RoundStoryV8): CanvasItem { return { itemType: "round-summary", id: `round:${round.roundIndex}:summary`, title: round.summary.title, roundIndex: round.roundIndex, round, detailRef: round.detailRef }; } export function finalResultItem(view: ExecutionViewV8): CanvasItem { return { itemType: "final-result", id: view.finalResult.id, title: view.finalResult.title, result: view.finalResult, detailRef: "run:final-result" }; } function canvasNode(item: CanvasItem, type: string, position: { x: number; y: number }, options: LayoutOptions, parentId?: string): Node { const resolvedType = hasDecision(item) ? "agentDecision" : type; return { id: item.id, type: resolvedType, position, parentId, extent: parentId ? "parent" : undefined, data: baseData(options, { item, expanded: item.itemType === "branch-summary" ? options.expandedBranches.has(branchKey(item.roundIndex, item.branchId)) : item.itemType === "retrieval-agent" ? options.expandedRetrievalAgents.has(item.id) : undefined }), draggable: true, selectable: true, focusable: false, zIndex: 4 }; } function baseData(options: LayoutOptions, extra: Partial): CanvasNodeData { return { buildId: options.buildId, onOpen: options.onOpen, onPrompt: options.onPrompt, onToggleRound: options.onToggleRound, onToggleBranch: options.onToggleBranch, onToggleRetrievalAgent: options.onToggleRetrievalAgent, ...extra }; } function hasDecision(item: CanvasItem) { return item.itemType === "story" ? Boolean(item.decision) : item.itemType === "multipath-review" ? Boolean(item.review.decisionProjection) : item.itemType === "multipath-decision" ? Boolean(item.decision.decisionProjection) : false; } function flowEdge(source: string, target: string, options: { status?: string; kind?: string } = {}): Edge { const color = options.kind === "unassigned" ? "#f4c152" : options.status === "merged" ? "#63c883" : options.status === "parked" ? "#f4c152" : options.status === "discarded" ? "#ff806e" : options.kind === "delegation" ? "#7bb8ff" : "#7e848f"; return { id: `edge:${source}:${target}`, source, target, type: "smoothstep", animated: options.status === "running", style: { stroke: color, strokeWidth: 1.7, strokeDasharray: options.kind === "delegation" || options.kind === "unassigned" ? "6 5" : undefined }, markerEnd: { type: MarkerType.ArrowClosed, color, width: 14, height: 14 } }; } function estimateStoryHeight(story: StoryNode, detailLevel: CanvasDetailLevel) { if (detailLevel === "overview") return 120; const visibleValues = [story.card.primary?.value, ...story.card.secondary.map((item) => item.value)].filter(Boolean).slice(0, detailLevel === "compact" ? 1 : undefined) as string[]; const lines = visibleValues.reduce((sum, value, index) => sum + Math.min(index === 0 ? 4 : 2, Math.max(1, Math.ceil(value.length / 25))), 0); return Math.max(detailLevel === "compact" ? 196 : 244, (detailLevel === "compact" ? 142 : 164) + lines * 23 + (story.sourceNotice && detailLevel === "full" ? 28 : 0)); } function estimateBranchHeight(branch: BranchStory, detailLevel: CanvasDetailLevel) { if (detailLevel === "overview") return 132; const values = [branch.title, branch.summary.task, branch.summary.output].filter(Boolean) as string[]; const lines = values.reduce((sum, value) => sum + Math.min(2, Math.max(1, Math.ceil(value.length / 25))), 0); const sourceAllowance = branch.outcome.sourceNotice || branch.task.sourceNotice || branch.output.node.sourceNotice ? 28 : 0; return Math.max(detailLevel === "compact" ? 232 : 320, (detailLevel === "full" ? 188 : 148) + lines * 23 + sourceAllowance); } function estimateMultipathReviewHeight(review: import("@/lib/types").MultipathReview, detailLevel: CanvasDetailLevel) { return estimateDecisionCardHeight(review.decisionProjection?.card, detailLevel); } function estimateMultipathDecisionHeight(decision: import("@/lib/types").MultipathDecisionBatch, detailLevel: CanvasDetailLevel) { return estimateDecisionCardHeight(decision.decisionProjection?.card, detailLevel); } function estimateDecisionCardHeight(card: import("@/lib/types").CardProjection | undefined, detailLevel: CanvasDetailLevel) { if (detailLevel === "overview") return 132; const values = card ? [card.primary?.value, ...card.secondary.map((item) => item.value)].filter(Boolean) as string[] : []; const visible = values.slice(0, detailLevel === "compact" ? 1 : 4); const lines = visible.reduce((sum, value, index) => sum + Math.min(index === 0 ? 4 : 2, Math.max(1, Math.ceil(value.length / 25))), 0); return Math.max(detailLevel === "compact" ? 216 : 296, (detailLevel === "compact" ? 150 : 190) + lines * 23); } function densityFor(detailLevel: CanvasDetailLevel) { if (detailLevel === "overview") return { laneGap: 12, contentInset: 12, branchFloor: 148 }; if (detailLevel === "compact") return { laneGap: 18, contentInset: 16, branchFloor: 208 }; return { laneGap: 28, contentInset: 24, branchFloor: 244 }; } function collapsedRoundHeight(detailLevel: CanvasDetailLevel) { if (detailLevel === "overview") return 164; if (detailLevel === "compact") return 280; return 400; } function branchKey(roundIndex: number, branchId: number) { return `${roundIndex}:${branchId}`; } function centeredRoundY(metrics: RoundMetrics, height: number) { return metrics.contentCenterY - height / 2; }