fix: remove deprecated graphs

This commit is contained in:
Timothy
2026-04-13 20:56:47 -07:00
parent 326d7f201c
commit aa281aad34
8 changed files with 163 additions and 2200 deletions
+4 -1
View File
@@ -36,7 +36,10 @@
"Bash(pkill -f \"pytest.*test_event_loop_node\")", "Bash(pkill -f \"pytest.*test_event_loop_node\")",
"Bash(pkill -f \"pytest.*TestToolConcurrency\")", "Bash(pkill -f \"pytest.*TestToolConcurrency\")",
"Bash(grep -n \"def.*discover\\\\|/api/agents\\\\|agents_discover\" /home/timothy/aden/hive/core/framework/server/*.py)", "Bash(grep -n \"def.*discover\\\\|/api/agents\\\\|agents_discover\" /home/timothy/aden/hive/core/framework/server/*.py)",
"Bash(bun run:*)" "Bash(bun run:*)",
"Bash(npx eslint:*)",
"Bash(npm run:*)",
"Bash(npm test:*)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/home/timothy/.hive/skills/writing-hive-skills", "/home/timothy/.hive/skills/writing-hive-skills",
-50
View File
@@ -176,56 +176,6 @@ export interface GraphTopology {
entry_points?: EntryPoint[]; entry_points?: EntryPoint[];
} }
// --- Draft graph types (planning phase) ---
export interface DraftNode {
id: string;
name: string;
description: string;
node_type: string;
tools: string[];
input_keys: string[];
output_keys: string[];
success_criteria: string;
sub_agents: string[];
/** For decision nodes: the yes/no question evaluated during dissolution. */
decision_clause?: string;
flowchart_type: string;
flowchart_shape: string;
flowchart_color: string;
}
export interface DraftEdge {
id: string;
source: string;
target: string;
condition: string;
description: string;
/** Short label shown on the flowchart edge (e.g. "Yes", "No"). */
label?: string;
}
export interface DraftGraph {
agent_name: string;
goal: string;
description: string;
success_criteria: string[];
constraints: string[];
nodes: DraftNode[];
edges: DraftEdge[];
entry_node: string;
terminal_nodes: string[];
flowchart_legend: Record<string, { shape: string; color: string }>;
}
/** Mapping from runtime graph nodes → original flowchart draft nodes. */
export interface FlowchartMap {
/** runtime_node_id → list of original draft node IDs it absorbed. */
map: Record<string, string[]> | null;
/** Original draft graph preserved before planning-node dissolution (decision + subagent). */
original_draft: DraftGraph | null;
}
export interface NodeCriteria { export interface NodeCriteria {
node_id: string; node_id: string;
success_criteria: string | null; success_criteria: string | null;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,143 @@
import { Clock, Webhook, Zap, ArrowRight, Activity } from "lucide-react";
import type { GraphNode } from "./graph-types";
import { cronToLabel } from "@/lib/graphUtils";
interface TriggersPanelProps {
triggers: GraphNode[];
selectedId?: string | null;
onSelect?: (trigger: GraphNode) => void;
}
function TriggerIcon({ type }: { type?: string }) {
const cls = "w-3.5 h-3.5";
switch (type) {
case "webhook":
return <Webhook className={cls} />;
case "timer":
return <Clock className={cls} />;
case "api":
return <ArrowRight className={cls} />;
case "event":
return <Activity className={cls} />;
default:
return <Zap className={cls} />;
}
}
function scheduleLabel(config: Record<string, unknown> | undefined): string | null {
if (!config) return null;
const cron = config.cron as string | undefined;
if (cron) return cronToLabel(cron);
const interval = config.interval_minutes as number | undefined;
if (interval != null) {
if (interval >= 60) return `Every ${interval / 60}h`;
return `Every ${interval}m`;
}
return null;
}
function countdownLabel(nextFireIn: number | undefined): string | null {
if (nextFireIn == null || nextFireIn <= 0) return null;
const h = Math.floor(nextFireIn / 3600);
const m = Math.floor((nextFireIn % 3600) / 60);
const s = Math.floor(nextFireIn % 60);
return h > 0
? `next in ${h}h ${String(m).padStart(2, "0")}m`
: `next in ${m}m ${String(s).padStart(2, "0")}s`;
}
function TriggerCard({
trigger,
selected,
onClick,
}: {
trigger: GraphNode;
selected: boolean;
onClick?: () => void;
}) {
const isActive = trigger.status === "running" || trigger.status === "complete";
const schedule = scheduleLabel(trigger.triggerConfig);
const nextFireIn = trigger.triggerConfig?.next_fire_in as number | undefined;
const countdown = isActive ? countdownLabel(nextFireIn) : null;
return (
<button
type="button"
onClick={onClick}
className={[
"w-full text-left rounded-lg border px-3 py-2.5 transition-colors",
selected
? "bg-primary/10 border-primary/30"
: "bg-background/60 border-border/30 hover:bg-muted/40 hover:border-border/50",
].join(" ")}
>
<div className="flex items-center gap-2">
<span
className={[
"flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center",
isActive ? "bg-primary/15 text-primary" : "bg-muted/60 text-muted-foreground",
].join(" ")}
>
<TriggerIcon type={trigger.triggerType} />
</span>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate">{trigger.label}</p>
{schedule && schedule !== trigger.label && (
<p className="text-[10.5px] text-muted-foreground truncate mt-0.5">{schedule}</p>
)}
</div>
<span
className={[
"flex-shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded-full",
isActive
? "bg-emerald-500/15 text-emerald-400"
: "bg-muted/60 text-muted-foreground",
].join(" ")}
>
{isActive ? "active" : "inactive"}
</span>
</div>
{countdown && (
<p className="text-[10px] text-muted-foreground mt-1.5 italic pl-8">{countdown}</p>
)}
</button>
);
}
export default function TriggersPanel({ triggers, selectedId, onSelect }: TriggersPanelProps) {
return (
<div className="flex flex-col h-full bg-card/30 border-l border-border/30">
<div className="px-4 py-3 border-b border-border/30 flex items-center gap-2">
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
<h3 className="text-xs font-semibold text-foreground uppercase tracking-wide">
Triggers
</h3>
{triggers.length > 0 && (
<span className="ml-auto text-[10px] text-muted-foreground">
{triggers.length}
</span>
)}
</div>
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-2">
{triggers.length === 0 ? (
<div className="text-center py-8">
<Clock className="w-6 h-6 mx-auto text-muted-foreground/40 mb-2" />
<p className="text-[11px] text-muted-foreground">No triggers configured</p>
<p className="text-[10px] text-muted-foreground/70 mt-1 px-2">
Ask the queen to set a schedule or webhook
</p>
</div>
) : (
triggers.map((t) => (
<TriggerCard
key={t.id}
trigger={t}
selected={selectedId === t.id}
onClick={onSelect ? () => onSelect(t) : undefined}
/>
))
)}
</div>
</div>
);
}
@@ -1,375 +0,0 @@
import { describe, it, expect } from "vitest";
import { topologyToGraphNodes } from "./graph-converter";
import type { GraphTopology, NodeSpec } from "@/api/types";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeNode(id: string, overrides: Partial<NodeSpec> = {}): NodeSpec {
return {
id,
name: id,
description: "",
node_type: "event_loop",
input_keys: [],
output_keys: [],
nullable_output_keys: [],
tools: [],
routes: {},
max_retries: 3,
max_node_visits: 0,
client_facing: false,
success_criteria: null,
system_prompt: "",
...overrides,
};
}
// ---------------------------------------------------------------------------
// Edge classification
// ---------------------------------------------------------------------------
describe("edge classification", () => {
it("linear chain: all edges in next[], no backEdges", () => {
const topology: GraphTopology = {
nodes: [makeNode("A"), makeNode("B"), makeNode("C")],
edges: [
{ source: "A", target: "B", condition: "on_success", priority: 0 },
{ source: "B", target: "C", condition: "on_success", priority: 0 },
],
entry_node: "A",
};
const result = topologyToGraphNodes(topology);
expect(result).toHaveLength(3);
const a = result.find((n) => n.id === "A")!;
const b = result.find((n) => n.id === "B")!;
const c = result.find((n) => n.id === "C")!;
expect(a.next).toEqual(["B"]);
expect(a.backEdges).toBeUndefined();
expect(b.next).toEqual(["C"]);
expect(b.backEdges).toBeUndefined();
expect(c.next).toBeUndefined();
expect(c.backEdges).toBeUndefined();
});
it("loop edge: classified as backEdge", () => {
const topology: GraphTopology = {
nodes: [makeNode("A"), makeNode("B"), makeNode("C")],
edges: [
{ source: "A", target: "B", condition: "on_success", priority: 0 },
{ source: "B", target: "C", condition: "on_success", priority: 0 },
{ source: "C", target: "A", condition: "on_success", priority: 0 },
],
entry_node: "A",
};
const result = topologyToGraphNodes(topology);
const c = result.find((n) => n.id === "C")!;
expect(c.next).toBeUndefined();
expect(c.backEdges).toEqual(["A"]);
});
it("diamond/fan-out: multiple next targets", () => {
const topology: GraphTopology = {
nodes: [makeNode("A"), makeNode("B"), makeNode("C"), makeNode("D")],
edges: [
{ source: "A", target: "B", condition: "on_success", priority: 0 },
{ source: "A", target: "C", condition: "on_failure", priority: 1 },
{ source: "B", target: "D", condition: "on_success", priority: 0 },
{ source: "C", target: "D", condition: "on_success", priority: 0 },
],
entry_node: "A",
};
const result = topologyToGraphNodes(topology);
const a = result.find((n) => n.id === "A")!;
expect(a.next).toEqual(expect.arrayContaining(["B", "C"]));
expect(a.next).toHaveLength(2);
});
});
// ---------------------------------------------------------------------------
// Status mapping
// ---------------------------------------------------------------------------
describe("status mapping", () => {
it("no enrichment: all nodes pending", () => {
const topology: GraphTopology = {
nodes: [makeNode("A"), makeNode("B")],
edges: [
{ source: "A", target: "B", condition: "on_success", priority: 0 },
],
entry_node: "A",
};
const result = topologyToGraphNodes(topology);
expect(result.every((n) => n.status === "pending")).toBe(true);
});
it("is_current: running", () => {
const topology: GraphTopology = {
nodes: [makeNode("A", { is_current: true, visit_count: 1, in_path: true })],
edges: [],
entry_node: "A",
};
const result = topologyToGraphNodes(topology);
expect(result[0].status).toBe("running");
});
it("is_current + visit_count > 1: looping", () => {
const topology: GraphTopology = {
nodes: [makeNode("A", { is_current: true, visit_count: 3, in_path: true })],
edges: [],
entry_node: "A",
};
const result = topologyToGraphNodes(topology);
expect(result[0].status).toBe("looping");
});
it("in_path + visited + not current: complete", () => {
const topology: GraphTopology = {
nodes: [makeNode("A", { in_path: true, visit_count: 1, is_current: false })],
edges: [],
entry_node: "A",
};
const result = topologyToGraphNodes(topology);
expect(result[0].status).toBe("complete");
});
it("has_failures: error", () => {
const topology: GraphTopology = {
nodes: [makeNode("A", { has_failures: true, in_path: true, visit_count: 1 })],
edges: [],
entry_node: "A",
};
const result = topologyToGraphNodes(topology);
expect(result[0].status).toBe("error");
});
});
// ---------------------------------------------------------------------------
// Iteration tracking
// ---------------------------------------------------------------------------
describe("iteration tracking", () => {
it("visit_count maps to iterations", () => {
const topology: GraphTopology = {
nodes: [makeNode("A", { visit_count: 3, in_path: true })],
edges: [],
entry_node: "A",
};
const result = topologyToGraphNodes(topology);
expect(result[0].iterations).toBe(3);
});
it("max_node_visits maps to maxIterations", () => {
const topology: GraphTopology = {
nodes: [makeNode("A", { max_node_visits: 5, visit_count: 1, in_path: true })],
edges: [],
entry_node: "A",
};
const result = topologyToGraphNodes(topology);
expect(result[0].maxIterations).toBe(5);
});
it("max_node_visits == 0 (unlimited): maxIterations omitted", () => {
const topology: GraphTopology = {
nodes: [makeNode("A", { max_node_visits: 0, visit_count: 1, in_path: true })],
edges: [],
entry_node: "A",
};
const result = topologyToGraphNodes(topology);
expect(result[0].maxIterations).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Edge labels
// ---------------------------------------------------------------------------
describe("edge labels", () => {
it("conditional edges produce edgeLabels, on_success/always do not", () => {
const topology: GraphTopology = {
nodes: [makeNode("A"), makeNode("B"), makeNode("C"), makeNode("D")],
edges: [
{ source: "A", target: "B", condition: "conditional", priority: 0 },
{ source: "A", target: "C", condition: "on_failure", priority: 1 },
{ source: "B", target: "D", condition: "on_success", priority: 0 },
{ source: "C", target: "D", condition: "always", priority: 0 },
],
entry_node: "A",
};
const result = topologyToGraphNodes(topology);
const a = result.find((n) => n.id === "A")!;
const b = result.find((n) => n.id === "B")!;
const c = result.find((n) => n.id === "C")!;
// A has conditional + on_failure edges → both get labels
expect(a.edgeLabels).toEqual({ B: "conditional", C: "on_failure" });
// B has on_success → no label
expect(b.edgeLabels).toBeUndefined();
// C has always → no label
expect(c.edgeLabels).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Node ordering
// ---------------------------------------------------------------------------
describe("node ordering", () => {
it("nodes returned in BFS walk order from entry_node, not input order", () => {
const topology: GraphTopology = {
// Input order: C, A, B — but BFS from A should yield A, B, C
nodes: [makeNode("C"), makeNode("A"), makeNode("B")],
edges: [
{ source: "A", target: "B", condition: "on_success", priority: 0 },
{ source: "B", target: "C", condition: "on_success", priority: 0 },
],
entry_node: "A",
};
const result = topologyToGraphNodes(topology);
expect(result.map((n) => n.id)).toEqual(["A", "B", "C"]);
});
it("empty topology returns empty array", () => {
const topology: GraphTopology = {
nodes: [],
edges: [],
entry_node: "",
};
const result = topologyToGraphNodes(topology);
expect(result).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// Trigger node synthesis from entry_points
// ---------------------------------------------------------------------------
describe("trigger node synthesis", () => {
it("single non-manual entry point: trigger node prepended before entry_node", () => {
const topology: GraphTopology = {
nodes: [makeNode("A"), makeNode("B")],
edges: [
{ source: "A", target: "B", condition: "on_success", priority: 0 },
],
entry_node: "A",
entry_points: [
{ id: "webhook", name: "Webhook Handler", entry_node: "A", trigger_type: "webhook", trigger_config: { url: "/hook" } },
],
};
const result = topologyToGraphNodes(topology);
expect(result).toHaveLength(3);
const trigger = result[0];
expect(trigger.id).toBe("__trigger_webhook");
expect(trigger.nodeType).toBe("trigger");
expect(trigger.triggerType).toBe("webhook");
expect(trigger.triggerConfig).toEqual({ url: "/hook" });
expect(trigger.label).toBe("Webhook Handler");
expect(trigger.status).toBe("pending");
expect(trigger.next).toEqual(["A"]);
});
it("trigger_config is threaded through for timer triggers", () => {
const topology: GraphTopology = {
nodes: [makeNode("A")],
edges: [],
entry_node: "A",
entry_points: [
{ id: "timer", name: "Daily Check", entry_node: "A", trigger_type: "timer", trigger_config: { cron: "0 9 * * *" } },
],
};
const result = topologyToGraphNodes(topology);
const trigger = result[0];
expect(trigger.triggerConfig).toEqual({ cron: "0 9 * * *" });
});
it("no entry_points: no trigger nodes added", () => {
const topology: GraphTopology = {
nodes: [makeNode("A")],
edges: [],
entry_node: "A",
};
const result = topologyToGraphNodes(topology);
expect(result).toHaveLength(1);
expect(result[0].nodeType).toBeUndefined();
});
it("only manual entry points: no trigger nodes added", () => {
const topology: GraphTopology = {
nodes: [makeNode("A")],
edges: [],
entry_node: "A",
entry_points: [
{ id: "main", name: "Main", entry_node: "A", trigger_type: "manual" },
],
};
const result = topologyToGraphNodes(topology);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("A");
});
it("multiple non-manual entry points: multiple trigger nodes", () => {
const topology: GraphTopology = {
nodes: [makeNode("A"), makeNode("B"), makeNode("C")],
edges: [
{ source: "A", target: "C", condition: "on_success", priority: 0 },
{ source: "B", target: "C", condition: "on_success", priority: 0 },
],
entry_node: "A",
entry_points: [
{ id: "webhook", name: "Webhook", entry_node: "A", trigger_type: "webhook" },
{ id: "timer", name: "Daily Timer", entry_node: "B", trigger_type: "timer" },
],
};
const result = topologyToGraphNodes(topology);
expect(result).toHaveLength(5); // 2 triggers + 3 nodes
const triggers = result.filter((n) => n.nodeType === "trigger");
expect(triggers).toHaveLength(2);
expect(triggers[0].next).toEqual(["A"]);
expect(triggers[1].next).toEqual(["B"]);
});
it("mix of manual and non-manual: only non-manual become trigger nodes", () => {
const topology: GraphTopology = {
nodes: [makeNode("A"), makeNode("B")],
edges: [
{ source: "A", target: "B", condition: "on_success", priority: 0 },
],
entry_node: "A",
entry_points: [
{ id: "main", name: "Main", entry_node: "A", trigger_type: "manual" },
{ id: "webhook", name: "Webhook", entry_node: "A", trigger_type: "webhook" },
],
};
const result = topologyToGraphNodes(topology);
expect(result).toHaveLength(3); // 1 trigger + 2 nodes
const triggers = result.filter((n) => n.nodeType === "trigger");
expect(triggers).toHaveLength(1);
expect(triggers[0].triggerType).toBe("webhook");
});
});
-186
View File
@@ -1,186 +0,0 @@
import type { GraphTopology, NodeSpec } from "@/api/types";
import type { GraphNode, NodeStatus } from "@/components/graph-types";
/**
* Convert a backend GraphTopology (nodes + edges + entry_node) into
* the GraphNode[] shape that DraftGraph renders.
*
* Four jobs:
* 1. Synthesize trigger nodes from non-manual entry_points
* 2. Order nodes via BFS from trigger/entry_node
* 3. Classify edges as forward (next) or backward (backEdges)
* 4. Map session enrichment fields to NodeStatus
*/
export function topologyToGraphNodes(topology: GraphTopology): GraphNode[] {
const { nodes: allNodes, edges, entry_node, entry_points } = topology;
if (allNodes.length === 0) return [];
// Filter out subagent-only nodes (referenced in sub_agents but not in any edge)
const subagentIds = new Set<string>();
for (const n of allNodes) {
for (const sa of n.sub_agents ?? []) {
subagentIds.add(sa);
}
}
const edgeParticipants = new Set<string>();
for (const e of edges) {
edgeParticipants.add(e.source);
edgeParticipants.add(e.target);
}
const nodes = allNodes.filter(
(n) =>
!subagentIds.has(n.id) ||
edgeParticipants.has(n.id) ||
n.id === entry_node,
);
// --- Synthesize trigger nodes for non-manual entry points ---
const schedulerEntryPoints = (entry_points || []).filter(
(ep) => ep.trigger_type !== "manual",
);
const triggerMap = new Map<string, GraphNode>();
for (const ep of schedulerEntryPoints) {
const triggerId = `__trigger_${ep.id}`;
triggerMap.set(triggerId, {
id: triggerId,
label: ep.name,
status: "pending",
nodeType: "trigger",
triggerType: ep.trigger_type,
triggerConfig: {
...ep.trigger_config,
...(ep.next_fire_in != null ? { next_fire_in: ep.next_fire_in } : {}),
...(ep.task ? { task: ep.task } : {}),
},
next: [ep.entry_node],
});
}
// Build adjacency list: source → [target, ...] (includes trigger edges)
const adj = new Map<string, string[]>();
for (const e of edges) {
const list = adj.get(e.source) || [];
list.push(e.target);
adj.set(e.source, list);
}
for (const [triggerId, triggerNode] of triggerMap) {
adj.set(triggerId, triggerNode.next!);
}
// BFS — start from trigger nodes (if any), then entry_node.
// Always include entry_node so the DAG ordering stays correct
// even when triggers target a node other than entry.
const order: string[] = [];
const position = new Map<string, number>();
const visited = new Set<string>();
const entryStart = entry_node || nodes[0].id;
const starts =
triggerMap.size > 0
? [...triggerMap.keys(), entryStart]
: [entryStart];
const queue = [...starts];
for (const s of starts) visited.add(s);
while (queue.length > 0) {
const id = queue.shift()!;
position.set(id, order.length);
order.push(id);
for (const target of adj.get(id) || []) {
if (!visited.has(target)) {
visited.add(target);
queue.push(target);
}
}
}
// Add any nodes not reachable from entry (shouldn't happen in valid graphs)
for (const n of nodes) {
if (!visited.has(n.id)) {
position.set(n.id, order.length);
order.push(n.id);
}
}
// Build a node lookup
const nodeMap = new Map<string, NodeSpec>();
for (const n of nodes) {
nodeMap.set(n.id, n);
}
// Classify edges per source node
const nextMap = new Map<string, string[]>();
const backMap = new Map<string, string[]>();
for (const e of edges) {
const srcPos = position.get(e.source) ?? 0;
const tgtPos = position.get(e.target) ?? 0;
if (tgtPos <= srcPos) {
// Back edge (target is at same or earlier position in BFS)
const list = backMap.get(e.source) || [];
list.push(e.target);
backMap.set(e.source, list);
} else {
// Forward edge
const list = nextMap.get(e.source) || [];
list.push(e.target);
nextMap.set(e.source, list);
}
}
// Build edge condition labels (only for non-trivial conditions)
const edgeLabelMap = new Map<string, Record<string, string>>();
for (const e of edges) {
if (e.condition !== "always" && e.condition !== "on_success") {
const labels = edgeLabelMap.get(e.source) || {};
labels[e.target] = e.condition;
edgeLabelMap.set(e.source, labels);
}
}
// Build GraphNode[] in BFS order
return order.map((id) => {
// Synthetic trigger nodes are returned directly
const trigger = triggerMap.get(id);
if (trigger) return trigger;
const spec = nodeMap.get(id);
const next = nextMap.get(id);
const back = backMap.get(id);
const labels = edgeLabelMap.get(id);
const result: GraphNode = {
id,
label: spec?.name || id,
status: mapStatus(spec),
...(next && next.length > 0 ? { next } : {}),
...(back && back.length > 0 ? { backEdges: back } : {}),
...(labels ? { edgeLabels: labels } : {}),
};
// Iteration tracking from session enrichment
if (spec?.visit_count !== undefined && spec.visit_count > 0) {
result.iterations = spec.visit_count;
}
if (spec?.max_node_visits !== undefined && spec.max_node_visits > 0) {
result.maxIterations = spec.max_node_visits;
}
return result;
});
}
function mapStatus(spec: NodeSpec | undefined): NodeStatus {
if (!spec) return "pending";
if (spec.has_failures) return "error";
if (spec.is_current) {
return (spec.visit_count ?? 0) > 1 ? "looping" : "running";
}
if (spec.in_path && (spec.visit_count ?? 0) > 0) return "complete";
return "pending";
}
+1 -2
View File
@@ -1,8 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
// ── Shared graph utilities ── // ── Shared graph utilities ──
// Common helpers used by both AgentGraph and DraftGraph. // Shared helpers for graph-like components (TriggersPanel, etc.).
// AgentGraph still has its own copies for now (separate cleanup PR).
/** Read a CSS custom property value (space-separated HSL components). */ /** Read a CSS custom property value (space-separated HSL components). */
export function cssVar(name: string): string { export function cssVar(name: string): string {
+15 -265
View File
@@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useParams, useLocation } from "react-router-dom"; import { useParams, useLocation } from "react-router-dom";
import { Loader2, WifiOff, KeyRound, FolderOpen, X } from "lucide-react"; import { Loader2, WifiOff, KeyRound, FolderOpen, X } from "lucide-react";
import type { GraphNode, NodeStatus } from "@/components/graph-types"; import type { GraphNode, NodeStatus } from "@/components/graph-types";
import DraftGraph from "@/components/DraftGraph"; import TriggersPanel from "@/components/TriggersPanel";
import ChatPanel, { type ChatMessage, type ImageContent } from "@/components/ChatPanel"; import ChatPanel, { type ChatMessage, type ImageContent } from "@/components/ChatPanel";
import NodeDetailPanel from "@/components/NodeDetailPanel"; import NodeDetailPanel from "@/components/NodeDetailPanel";
import CredentialsModal, { import CredentialsModal, {
@@ -10,17 +10,10 @@ import CredentialsModal, {
clearCredentialCache, clearCredentialCache,
} from "@/components/CredentialsModal"; } from "@/components/CredentialsModal";
import { executionApi } from "@/api/execution"; import { executionApi } from "@/api/execution";
import { workersApi } from "@/api/workers";
import { sessionsApi } from "@/api/sessions"; import { sessionsApi } from "@/api/sessions";
import { useMultiSSE } from "@/hooks/use-sse"; import { useMultiSSE } from "@/hooks/use-sse";
import type { import type { LiveSession, AgentEvent } from "@/api/types";
LiveSession,
AgentEvent,
NodeSpec,
DraftGraph as DraftGraphData,
} from "@/api/types";
import { sseEventToChatMessage, formatAgentDisplayName } from "@/lib/chat-helpers"; import { sseEventToChatMessage, formatAgentDisplayName } from "@/lib/chat-helpers";
import { topologyToGraphNodes } from "@/lib/graph-converter";
import { cronToLabel } from "@/lib/graphUtils"; import { cronToLabel } from "@/lib/graphUtils";
import { ApiError } from "@/api/client"; import { ApiError } from "@/api/client";
import { useColony } from "@/context/ColonyContext"; import { useColony } from "@/context/ColonyContext";
@@ -48,8 +41,6 @@ function truncate(s: string, max: number): string {
type SessionRestoreResult = { type SessionRestoreResult = {
messages: ChatMessage[]; messages: ChatMessage[];
restoredPhase: "planning" | "building" | "staging" | "running" | "independent" | null; restoredPhase: "planning" | "building" | "staging" | "running" | "independent" | null;
flowchartMap: Record<string, string[]> | null;
originalDraft: DraftGraphData | null;
}; };
async function restoreSessionMessages( async function restoreSessionMessages(
@@ -62,8 +53,6 @@ async function restoreSessionMessages(
if (events.length > 0) { if (events.length > 0) {
const messages: ChatMessage[] = []; const messages: ChatMessage[] = [];
let runningPhase: ChatMessage["phase"] = undefined; let runningPhase: ChatMessage["phase"] = undefined;
let flowchartMap: Record<string, string[]> | null = null;
let originalDraft: DraftGraphData | null = null;
for (const evt of events) { for (const evt of events) {
const p = const p =
evt.type === "queen_phase_changed" evt.type === "queen_phase_changed"
@@ -74,14 +63,6 @@ async function restoreSessionMessages(
if (p && ["planning", "building", "staging", "running"].includes(p)) { if (p && ["planning", "building", "staging", "running"].includes(p)) {
runningPhase = p as ChatMessage["phase"]; runningPhase = p as ChatMessage["phase"];
} }
if (evt.type === "custom" && (evt.data as Record<string, unknown>)?.event === "flowchart_updated" && evt.data) {
const mapData = evt.data as {
map?: Record<string, string[]>;
original_draft?: DraftGraphData;
};
flowchartMap = mapData.map ?? null;
originalDraft = mapData.original_draft ?? null;
}
const msg = sseEventToChatMessage(evt, thread, agentDisplayName); const msg = sseEventToChatMessage(evt, thread, agentDisplayName);
if (!msg) continue; if (!msg) continue;
if (evt.stream_id === "queen") { if (evt.stream_id === "queen") {
@@ -90,12 +71,12 @@ async function restoreSessionMessages(
} }
messages.push(msg); messages.push(msg);
} }
return { messages, restoredPhase: runningPhase ?? null, flowchartMap, originalDraft }; return { messages, restoredPhase: runningPhase ?? null };
} }
} catch { } catch {
// Event log not available // Event log not available
} }
return { messages: [], restoredPhase: null, flowchartMap: null, originalDraft: null }; return { messages: [], restoredPhase: null };
} }
// ── Agent backend state ────────────────────────────────────────────────────── // ── Agent backend state ──────────────────────────────────────────────────────
@@ -107,18 +88,10 @@ interface AgentState {
queenReady: boolean; queenReady: boolean;
error: string | null; error: string | null;
displayName: string | null; displayName: string | null;
colonyId: string | null; nodeSpecs: NodeSpec[];
awaitingInput: boolean; awaitingInput: boolean;
workerInputMessageId: string | null; workerInputMessageId: string | null;
queenBuilding: boolean;
queenPhase: "planning" | "building" | "staging" | "running" | "independent"; queenPhase: "planning" | "building" | "staging" | "running" | "independent";
designingDraft: boolean;
draftGraph: DraftGraphData | null;
originalDraft: DraftGraphData | null;
flowchartMap: Record<string, string[]> | null;
agentPath: string | null; agentPath: string | null;
workerRunState: "idle" | "deploying" | "running";
currentExecutionId: string | null;
currentRunId: string | null; currentRunId: string | null;
nodeLogs: Record<string, string[]>; nodeLogs: Record<string, string[]>;
nodeActionPlans: Record<string, string>; nodeActionPlans: Record<string, string>;
@@ -153,19 +126,10 @@ function defaultAgentState(): AgentState {
queenReady: false, queenReady: false,
error: null, error: null,
displayName: null, displayName: null,
colonyId: null,
nodeSpecs: [],
awaitingInput: false, awaitingInput: false,
workerInputMessageId: null, workerInputMessageId: null,
queenBuilding: false,
queenPhase: "planning", queenPhase: "planning",
designingDraft: false,
draftGraph: null,
originalDraft: null,
flowchartMap: null,
agentPath: null, agentPath: null,
workerRunState: "idle",
currentExecutionId: null,
currentRunId: null, currentRunId: null,
nodeLogs: {}, nodeLogs: {},
nodeActionPlans: {}, nodeActionPlans: {},
@@ -239,9 +203,6 @@ export default function ColonyChat() {
const [credentialAgentPath, setCredentialAgentPath] = useState<string | null>(null); const [credentialAgentPath, setCredentialAgentPath] = useState<string | null>(null);
const [dismissedBanner, setDismissedBanner] = useState<string | null>(null); const [dismissedBanner, setDismissedBanner] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null); const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
const [graphPanelPct, setGraphPanelPct] = useState(30);
const savedGraphPanelPct = useRef(30);
const resizing = useRef(false);
// ── Header actions (Credentials, Data, Browser) ───────────────────────── // ── Header actions (Credentials, Data, Browser) ─────────────────────────
useEffect(() => { useEffect(() => {
@@ -281,8 +242,6 @@ export default function ColonyChat() {
const queenIterTextRef = useRef<Record<string, Record<number, string>>>({}); const queenIterTextRef = useRef<Record<string, Record<number, string>>>({});
const suppressIntroRef = useRef(false); const suppressIntroRef = useRef(false);
const loadingRef = useRef(false); const loadingRef = useRef(false);
const designingDraftSinceRef = useRef(0);
const designingDraftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// ── Helpers ────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────
@@ -346,71 +305,11 @@ export default function ColonyChat() {
})); }));
}, []); }, []);
// ── Drag-to-resize graph panel ──────────────────────────────────────────
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (!resizing.current) return;
const sidebarWidth = 240;
const pct = 100 - ((e.clientX - sidebarWidth) / (window.innerWidth - sidebarWidth)) * 100;
setGraphPanelPct(Math.max(15, Math.min(50, pct)));
};
const onMouseUp = () => {
resizing.current = false;
document.body.style.cursor = "";
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
}, []);
const nodeIsSelected = selectedNode !== null;
useEffect(() => {
if (nodeIsSelected) {
savedGraphPanelPct.current = graphPanelPct;
setGraphPanelPct((prev) => Math.min(prev, 30));
} else {
setGraphPanelPct(savedGraphPanelPct.current);
}
}, [nodeIsSelected]); // eslint-disable-line react-hooks/exhaustive-deps
// Reset dismissed banner when the error clears // Reset dismissed banner when the error clears
useEffect(() => { useEffect(() => {
if (!agentState.error) setDismissedBanner(null); if (!agentState.error) setDismissedBanner(null);
}, [agentState.error]); }, [agentState.error]);
// ── Graph fetching ─────────────────────────────────────────────────────
const fetchGraph = useCallback(
async (sessionId: string, knownGraphId?: string) => {
try {
let colonyId = knownGraphId;
if (!colonyId) {
// Try session detail first (colony_id is always set when worker is loaded)
try {
const detail = await sessionsApi.get(sessionId);
colonyId = detail.colony_id ?? undefined;
} catch { /* fall through */ }
}
if (!colonyId) {
const { colonies } = await sessionsApi.colonies(sessionId);
if (!colonies.length) return;
colonyId = colonies[0];
}
const topology = await workersApi.nodes(sessionId, colonyId);
updateState({ colonyId, nodeSpecs: topology.nodes });
const nodes = topologyToGraphNodes(topology);
if (nodes.length > 0) setGraphNodes(nodes);
} catch {
// Graph fetch failed
}
},
[updateState],
);
// ── Session loading ──────────────────────────────────────────────────── // ── Session loading ────────────────────────────────────────────────────
const loadSession = useCallback(async () => { const loadSession = useCallback(async () => {
@@ -473,8 +372,6 @@ export default function ColonyChat() {
} }
let restoredPhase: "planning" | "building" | "staging" | "running" | "independent" | null = null; let restoredPhase: "planning" | "building" | "staging" | "running" | "independent" | null = null;
let restoredFlowchartMap: Record<string, string[]> | null = null;
let restoredOriginalDraft: DraftGraphData | null = null;
if (!liveSession) { if (!liveSession) {
// Pre-fetch messages from cold session // Pre-fetch messages from cold session
@@ -484,8 +381,6 @@ export default function ColonyChat() {
const restored = await restoreSessionMessages(coldRestoreId, agentPath, displayName); const restored = await restoreSessionMessages(coldRestoreId, agentPath, displayName);
preRestoredMsgs = restored.messages; preRestoredMsgs = restored.messages;
restoredPhase = restored.restoredPhase; restoredPhase = restored.restoredPhase;
restoredFlowchartMap = restored.flowchartMap;
restoredOriginalDraft = restored.originalDraft;
} }
if (coldRestoreId || preRestoredMsgs.length > 0) { if (coldRestoreId || preRestoredMsgs.length > 0) {
@@ -511,10 +406,7 @@ export default function ColonyChat() {
sessionId: session.session_id, sessionId: session.session_id,
displayName, displayName,
queenPhase: initialPhase, queenPhase: initialPhase,
queenBuilding: initialPhase === "building",
queenSupportsImages: session.queen_supports_images !== false, queenSupportsImages: session.queen_supports_images !== false,
...(restoredFlowchartMap ? { flowchartMap: restoredFlowchartMap } : {}),
...(restoredOriginalDraft ? { originalDraft: restoredOriginalDraft, draftGraph: null } : {}),
}); });
// Restore messages for live resume // Restore messages for live resume
@@ -528,10 +420,6 @@ export default function ColonyChat() {
restored.messages.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0)); restored.messages.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
setMessages(restored.messages); setMessages(restored.messages);
} }
if (restored.flowchartMap && !restoredFlowchartMap) {
restoredFlowchartMap = restored.flowchartMap;
restoredOriginalDraft = restored.originalDraft;
}
} }
const hasRestoredContent = isResumedSession || !!coldRestoreId; const hasRestoredContent = isResumedSession || !!coldRestoreId;
@@ -543,8 +431,6 @@ export default function ColonyChat() {
ready: true, ready: true,
loading: false, loading: false,
queenReady: hasRestoredContent, queenReady: hasRestoredContent,
...(restoredFlowchartMap ? { flowchartMap: restoredFlowchartMap } : {}),
...(restoredOriginalDraft ? { originalDraft: restoredOriginalDraft, draftGraph: null } : {}),
}); });
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError && err.status === 424) { if (err instanceof ApiError && err.status === 424) {
@@ -578,13 +464,6 @@ export default function ColonyChat() {
} }
}, [agentPath, isNewChat]); // eslint-disable-line react-hooks/exhaustive-deps }, [agentPath, isNewChat]); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch graph when session becomes ready
useEffect(() => {
if (agentState.sessionId && agentState.ready && !agentState.colonyId) {
fetchGraph(agentState.sessionId);
}
}, [agentState.sessionId, agentState.ready, agentState.colonyId, fetchGraph]);
// ── SSE event handler ────────────────────────────────────────────────── // ── SSE event handler ──────────────────────────────────────────────────
const handleSSEEvent = useCallback( const handleSSEEvent = useCallback(
@@ -635,8 +514,6 @@ export default function ColonyChat() {
isStreaming: false, isStreaming: false,
workerIsTyping: true, workerIsTyping: true,
awaitingInput: false, awaitingInput: false,
workerRunState: "running",
currentExecutionId: event.execution_id || state.currentExecutionId || null,
currentRunId: incomingRunId, currentRunId: incomingRunId,
nodeLogs: {}, nodeLogs: {},
subagentReports: [], subagentReports: [],
@@ -662,8 +539,6 @@ export default function ColonyChat() {
workerIsTyping: false, workerIsTyping: false,
awaitingInput: false, awaitingInput: false,
workerInputMessageId: null, workerInputMessageId: null,
workerRunState: "idle",
currentExecutionId: null,
llmSnapshots: {}, llmSnapshots: {},
pendingQuestion: null, pendingQuestion: null,
pendingOptions: null, pendingOptions: null,
@@ -671,7 +546,6 @@ export default function ColonyChat() {
pendingQuestionSource: null, pendingQuestionSource: null,
}); });
markAllNodesAs(["running", "looping"], "complete"); markAllNodesAs(["running", "looping"], "complete");
if (state.sessionId) fetchGraph(state.sessionId, state.colonyId || undefined);
} }
break; break;
@@ -745,7 +619,6 @@ export default function ColonyChat() {
isTyping: false, isTyping: false,
isStreaming: false, isStreaming: false,
queenIsTyping: false, queenIsTyping: false,
queenBuilding: false,
pendingQuestion: prompt || null, pendingQuestion: prompt || null,
pendingOptions: options, pendingOptions: options,
pendingQuestions: questions, pendingQuestions: questions,
@@ -767,7 +640,6 @@ export default function ColonyChat() {
pendingQuestionSource: null, pendingQuestionSource: null,
}); });
if (!isQueen) { if (!isQueen) {
updateState({ workerRunState: "idle", currentExecutionId: null });
markAllNodesAs(["running", "looping"], "pending"); markAllNodesAs(["running", "looping"], "pending");
} }
} }
@@ -785,7 +657,6 @@ export default function ColonyChat() {
pendingQuestionSource: null, pendingQuestionSource: null,
}); });
if (!isQueen) { if (!isQueen) {
updateState({ workerRunState: "idle", currentExecutionId: null });
if (event.node_id) { if (event.node_id) {
updateGraphNodeStatus(event.node_id, "error"); updateGraphNodeStatus(event.node_id, "error");
const errMsg = (event.data?.error as string) || "unknown error"; const errMsg = (event.data?.error as string) || "unknown error";
@@ -898,12 +769,6 @@ export default function ColonyChat() {
const toolName = (event.data?.tool_name as string) || "unknown"; const toolName = (event.data?.tool_name as string) || "unknown";
const toolUseId = (event.data?.tool_use_id as string) || ""; const toolUseId = (event.data?.tool_use_id as string) || "";
if (isQueen && toolName === "save_agent_draft") {
designingDraftSinceRef.current = Date.now();
if (designingDraftTimerRef.current) clearTimeout(designingDraftTimerRef.current);
updateState({ designingDraft: true });
}
const sid = event.stream_id; const sid = event.stream_id;
setAgentState((prev) => { setAgentState((prev) => {
const newActive = { const newActive = {
@@ -1002,7 +867,7 @@ export default function ColonyChat() {
} }
case "credentials_required": { case "credentials_required": {
updateState({ workerRunState: "idle", error: "credentials_required" }); updateState({ error: "credentials_required" });
const credAgentPath = event.data?.agent_path as string | undefined; const credAgentPath = event.data?.agent_path as string | undefined;
if (credAgentPath) setCredentialAgentPath(credAgentPath); if (credAgentPath) setCredentialAgentPath(credAgentPath);
setCredentialsOpen(true); setCredentialsOpen(true);
@@ -1025,64 +890,20 @@ export default function ColonyChat() {
queenPhaseRef.current = newPhase; queenPhaseRef.current = newPhase;
updateState({ updateState({
queenPhase: newPhase, queenPhase: newPhase,
queenBuilding: newPhase === "building",
workerRunState: newPhase === "running" ? "running" : "idle",
...(newPhase === "planning" ? { originalDraft: null, flowchartMap: null } : {}),
...(eventAgentPath ? { agentPath: eventAgentPath } : {}), ...(eventAgentPath ? { agentPath: eventAgentPath } : {}),
}); });
break; break;
} }
case "custom": {
const customEvent = event.data as Record<string, unknown>;
if (customEvent?.event === "draft_updated") {
const draft = customEvent as unknown as DraftGraphData | undefined;
if (draft?.nodes) {
const MIN_SPINNER_MS = 600;
const since = designingDraftSinceRef.current;
const elapsed = Date.now() - since;
const remaining = Math.max(0, MIN_SPINNER_MS - elapsed);
if (remaining > 0 && since > 0) {
updateState({ draftGraph: draft });
designingDraftTimerRef.current = setTimeout(() => {
updateState({ designingDraft: false });
}, remaining);
} else {
updateState({ draftGraph: draft, designingDraft: false });
}
}
} else if (customEvent?.event === "flowchart_updated") {
const mapData = customEvent as {
map?: Record<string, string[]>;
original_draft?: DraftGraphData;
};
if (mapData) {
updateState({
flowchartMap: mapData.map ?? null,
originalDraft: mapData.original_draft ?? null,
draftGraph: null,
});
}
}
break;
}
case "worker_colony_loaded": { case "worker_colony_loaded": {
const graphName = event.data?.colony_name as string | undefined; const graphName = event.data?.colony_name as string | undefined;
const agentPathFromEvent = event.data?.agent_path as string | undefined; const agentPathFromEvent = event.data?.agent_path as string | undefined;
const dn = formatAgentDisplayName(graphName || agentSlug(agentPath)); const dn = formatAgentDisplayName(graphName || agentSlug(agentPath));
clearCredentialCache(agentPathFromEvent); clearCredentialCache(agentPathFromEvent);
updateState({ updateState({ displayName: dn });
displayName: dn,
queenBuilding: false,
workerRunState: "idle",
colonyId: null,
nodeSpecs: [],
});
setGraphNodes([]); setGraphNodes([]);
// Remove old worker messages // Remove old worker messages
setMessages((prev) => prev.filter((m) => m.role !== "worker")); setMessages((prev) => prev.filter((m) => m.role !== "worker"));
if (state.sessionId) fetchGraph(state.sessionId);
break; break;
} }
@@ -1165,7 +986,7 @@ export default function ColonyChat() {
break; break;
} }
}, },
[agentPath, queenInfo.name, updateState, upsertMessage, updateGraphNodeStatus, markAllNodesAs, appendNodeLog, fetchGraph, graphNodes], [agentPath, queenInfo.name, updateState, upsertMessage, updateGraphNodeStatus, markAllNodesAs, appendNodeLog, graphNodes],
); );
// ── SSE subscription ─────────────────────────────────────────────────── // ── SSE subscription ───────────────────────────────────────────────────
@@ -1181,46 +1002,6 @@ export default function ColonyChat() {
// ── Action handlers ──────────────────────────────────────────────────── // ── Action handlers ────────────────────────────────────────────────────
const handleRun = useCallback(async () => {
if (!agentState.sessionId || !agentState.ready) return;
setDismissedBanner(null);
try {
updateState({ workerRunState: "deploying" });
const result = await executionApi.trigger(agentState.sessionId, "default", {});
updateState({ currentExecutionId: result.execution_id });
} catch (err) {
if (err instanceof ApiError && err.status === 424) {
const errBody = (err as ApiError).body as Record<string, unknown>;
const credPath = (errBody?.agent_path as string) || null;
if (credPath) setCredentialAgentPath(credPath);
updateState({ workerRunState: "idle", error: "credentials_required" });
setCredentialsOpen(true);
return;
}
const errMsg = err instanceof Error ? err.message : String(err);
upsertMessage({
id: makeId(),
agent: "System",
agentColor: "",
content: `Failed to trigger run: ${errMsg}`,
timestamp: "",
type: "system",
thread: agentPath,
createdAt: Date.now(),
});
updateState({ workerRunState: "idle" });
}
}, [agentState.sessionId, agentState.ready, agentPath, updateState, upsertMessage]);
const handlePause = useCallback(async () => {
if (!agentState.sessionId || !agentState.currentExecutionId) return;
try {
await executionApi.pause(agentState.sessionId, agentState.currentExecutionId);
} catch {
// fire-and-forget
}
}, [agentState.sessionId, agentState.currentExecutionId]);
const handleCancelQueen = useCallback(async () => { const handleCancelQueen = useCallback(async () => {
if (!agentState.sessionId) return; if (!agentState.sessionId) return;
try { try {
@@ -1416,44 +1197,14 @@ export default function ColonyChat() {
/> />
</div> </div>
{/* Pipeline graph panel */} {/* Triggers sidebar */}
<div <div className="w-[260px] flex-shrink-0">
className="bg-card/30 flex flex-col border-l border-border/30 relative" <TriggersPanel
style={{ width: `${graphPanelPct}%`, minWidth: 240, flexShrink: 0 }} triggers={graphNodes.filter((n) => n.nodeType === "trigger")}
> selectedId={resolvedSelectedNode?.id ?? null}
<div className="flex-1 min-h-0"> onSelect={(trigger) =>
<DraftGraph setSelectedNode((prev) => (prev?.id === trigger.id ? null : trigger))
key={colonyId} }
draft={agentState.originalDraft ?? agentState.draftGraph ?? null}
originalDraft={agentState.originalDraft ?? null}
loadingMessage={
agentState.designingDraft
? "Designing flowchart..."
: !agentState.originalDraft &&
!agentState.draftGraph &&
agentState.queenPhase !== "planning"
? "Loading flowchart..."
: null
}
building={agentState.queenBuilding}
onRun={handleRun}
onPause={handlePause}
runState={agentState.workerRunState}
flowchartMap={agentState.flowchartMap ?? undefined}
runtimeNodes={graphNodes}
onRuntimeNodeClick={(runtimeNodeId) => {
const node = graphNodes.find((n) => n.id === runtimeNodeId);
if (node) setSelectedNode((prev) => (prev?.id === node.id ? null : node));
}}
/>
</div>
{/* Resize handle */}
<div
className="absolute top-0 left-0 w-1 h-full cursor-col-resize hover:bg-primary/30 active:bg-primary/40 transition-colors z-10"
onMouseDown={() => {
resizing.current = true;
document.body.style.cursor = "col-resize";
}}
/> />
</div> </div>
@@ -1463,7 +1214,6 @@ export default function ColonyChat() {
<NodeDetailPanel <NodeDetailPanel
node={resolvedSelectedNode} node={resolvedSelectedNode}
sessionId={agentState.sessionId || ""} sessionId={agentState.sessionId || ""}
colonyId={agentState.colonyId || ""}
nodeLogs={agentState.nodeLogs[resolvedSelectedNode.id] || []} nodeLogs={agentState.nodeLogs[resolvedSelectedNode.id] || []}
actionPlan={agentState.nodeActionPlans[resolvedSelectedNode.id]} actionPlan={agentState.nodeActionPlans[resolvedSelectedNode.id]}
onClose={() => setSelectedNode(null)} onClose={() => setSelectedNode(null)}