Skip to content

Campaign Graph View

The campaign graph view renders plan execution as a vertical flow of connected nodes. Each node represents a primary action (send email, send SMS, complete plan), with supporting tool calls grouped as collapsible context entries.

The graph transforms a flat list of plan log entries into a grouped, connected node structure:

PlanLogDisplay[] → build_graph_data() → GraphData { nodes, edges }
src/mods/plan/types/plan_graph_type.rs
pub struct GraphNode {
pub id: Uuid,
pub log_entry: PlanLogDisplay, // The primary action
pub supporting: Vec<PlanLogDisplay>, // Grouped context entries
pub position_index: usize, // 0 = newest
pub execution_run: Option<usize>, // Run number for cross-run edges
}
pub struct GraphEdge {
pub from: Uuid,
pub to: Uuid,
pub is_cross_run: bool, // True when edge spans a ScheduleNextExecution boundary
}
pub struct GraphData {
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphEdge>,
}

Every tool call is classified into one of three roles that drive the grouping algorithm:

src/mods/plan/types/plan_log_entry_type.rs
pub enum ToolCallRole {
Primary, // Top-level graph node
PostAction, // Attaches to the previous primary node
Supporting, // Attaches to the next primary node
}
RoleTool CallsBehavior
PrimarySendEmail, SendSms, AskUser, CompletePlan, FailPlan, ScheduleNextExecutionCreates a new graph node
PostActionWriteInteractionNote, UpdateSystemNoteAppends to the previous primary node’s supporting list
SupportingListPlanContacts, GetContactDetails, GetContactNotes, GetConversationHistoryBuffers until the next primary node, then attaches as context

Classification lives on PlanLogEntryToolCall via role() and display_name() methods. Adding a new tool call variant requires choosing its role in the exhaustive match — the compiler enforces this.

build_graph_data() in src/mods/plan/build_graph_data.rs walks log entries chronologically:

  1. Buffer supporting entries as they arrive
  2. When a primary entry appears:
    • Split the buffer: PostAction entries attach to the previous node, remaining entries attach to the current node
    • Create a new GraphNode with the primary entry and its supporting context
  3. Track execution runs — increment when a ScheduleNextExecution is followed by an ExecutionStarted system event
  4. Reverse nodes to newest-first order and assign position_index values
  5. Build edges between consecutive nodes, marking is_cross_run: true when execution_run differs

plan_graph_component.rs — the main container. Renders a vertical spine line, state-specific indicators, and iterates over GraphData:

  • PendingReview → approve/reject start node with amber glow
  • Executing → ghost node with spinner animation
  • StandBy → badge with clock icon and next_execution_at timestamp
  • AwaitingInput → auto-expands the first node so the pending question is visible

Run boundaries appear as labeled dividers between execution runs: “Run 2 · 2026-03-10 14:00”.

plan_graph_node_component.rs — an expandable card for each primary action:

  • Collapsed: icon (color-coded by tool type), display name, truncated description, status dot, supporting entry count badge
  • Expanded: full tool content — email/SMS body rendered as Markdown, AskUser question with option buttons and free-text input, schedule details with scheduled_at/expires_at
  • Supporting context: listed in a “Context” section with left-border color coding
  • Inline actions: approve/reject buttons for pending outbound tools, answer interface for questions

Node styling varies by tool call status:

StatusStyle
pendingAmber dashed border, breathing glow, action buttons visible
approvedBlue solid border with inset shadow
donePrimary border with outer glow
rejected / expiredMuted, reduced opacity
failedRed border with shadow

plan_graph_edge_component.rs — SVG vertical connector between nodes with a midpoint dot. Cross-run edges render with a dashed stroke pattern (stroke-dasharray: "4,3").

The plan details view (plan_details_view.rs) uses a tab switcher:

  • Graph (default) — the campaign graph visualization
  • Timeline — the original chronological audit log, preserved for detailed inspection

Both views share the same use_realtime_plan() hook for live WebSocket updates. New log entries and state changes merge into the graph in real time.

When you add a new tool call variant to PlanLogEntryToolCall:

  1. Add the variant to the enum
  2. The compiler forces you to handle it in role() — choose Primary, PostAction, or Supporting
  3. Add a label in display_name()
  4. The graph automatically picks it up — no component changes needed