An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.

feat(web): add decision graph data transformation and stylesheet

Implement decision-graph.ts with:
- decisionsToElements(): Convert GraphNode/GraphEdge to Cytoscape ElementDefinition[]
* Each node includes: id, label (title), type, status, description, priority, assigned_to, metadata
* Each edge includes: id, source, target, label, edgeType

- filterNowMode(): Filter for 'Now' mode (active decisions only)
* Include Decision nodes with status 'active' or 'decided'
* Include Option nodes with status 'chosen'
* Include Outcome nodes with status 'active' or 'completed'
* Exclude: rejected, abandoned, superseded nodes
* Prune edges where either endpoint is filtered out

- decisionStylesheet(): Return Cytoscape stylesheet with node-type styling
* Decision: diamond shape, gold (#FFD700) background, dark border
* Option chosen: hexagon, green (#32CD32)
* Option rejected: hexagon, gray (#666), dashed border
* Option abandoned: hexagon, muted red (#CD5C5C), dashed border
* Outcome completed: ellipse, green
* Outcome active: ellipse, blue (#4169E1)
* Revisit: triangle, orange (#FFA500)
* Edges: straight lines with arrow, chosen=thicker+green, rejected=dashed+gray
* Selected nodes: red border

TypeScript verification: passed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+276
+276
web/src/lib/decision-graph.ts
··· 1 + /** 2 + * Decision graph transformation utilities. 3 + * Converts API responses to Cytoscape element definitions and manages filtering for Now/History modes. 4 + */ 5 + 6 + import type { ElementDefinition } from 'cytoscape'; 7 + import type cytoscape from 'cytoscape'; 8 + import type { GraphNode, GraphEdge, NodeType, NodeStatus, EdgeType } from '../types'; 9 + 10 + /** 11 + * Convert GraphNode and GraphEdge arrays to Cytoscape ElementDefinition format. 12 + * Nodes include type and status for styling. 13 + * Edges include edgeType for styling and label from the edge data. 14 + */ 15 + export function decisionsToElements( 16 + nodes: Array<GraphNode>, 17 + edges: Array<GraphEdge>, 18 + ): Array<ElementDefinition> { 19 + const nodeElements: Array<ElementDefinition> = nodes.map((node) => ({ 20 + data: { 21 + id: node.id, 22 + label: node.title, 23 + type: node.node_type, 24 + status: node.status, 25 + description: node.description, 26 + priority: node.priority, 27 + assigned_to: node.assigned_to, 28 + metadata: node.metadata, 29 + }, 30 + })); 31 + 32 + const edgeElements: Array<ElementDefinition> = edges.map((edge) => ({ 33 + data: { 34 + id: edge.id, 35 + source: edge.from_node, 36 + target: edge.to_node, 37 + label: edge.label || edge.edge_type, 38 + edgeType: edge.edge_type, 39 + }, 40 + })); 41 + 42 + return [...nodeElements, ...edgeElements]; 43 + } 44 + 45 + /** 46 + * Filter nodes and edges for "Now" mode (current active decisions). 47 + * Includes: 48 + * - Decision nodes with status 'active' or 'decided' 49 + * - Option nodes with status 'chosen' 50 + * - Outcome nodes with status 'active' or 'completed' 51 + * Excludes: 'rejected', 'abandoned', 'superseded' nodes 52 + * Prunes edges where either endpoint is filtered out. 53 + */ 54 + export function filterNowMode( 55 + nodes: Array<GraphNode>, 56 + edges: Array<GraphEdge>, 57 + ): { nodes: Array<GraphNode>; edges: Array<GraphEdge> } { 58 + // Determine which nodes to include 59 + const nowModeStatuses: Set<NodeStatus> = new Set([ 60 + 'active', 61 + 'decided', 62 + 'chosen', 63 + 'completed', 64 + ]); 65 + 66 + const filteredNodes = nodes.filter((node) => { 67 + const isDecision = node.node_type === 'decision'; 68 + const isOption = node.node_type === 'option'; 69 + const isOutcome = node.node_type === 'outcome'; 70 + 71 + // Decision: include if active or decided 72 + if (isDecision) { 73 + return node.status === 'active' || node.status === 'decided'; 74 + } 75 + 76 + // Option: include only if chosen 77 + if (isOption) { 78 + return node.status === 'chosen'; 79 + } 80 + 81 + // Outcome: include if active or completed 82 + if (isOutcome) { 83 + return node.status === 'active' || node.status === 'completed'; 84 + } 85 + 86 + // Other node types not included in decision graph 87 + return false; 88 + }); 89 + 90 + const includedNodeIds = new Set(filteredNodes.map((n) => n.id)); 91 + 92 + // Filter edges: both endpoints must exist in filtered nodes 93 + const filteredEdges = edges.filter( 94 + (edge) => includedNodeIds.has(edge.from_node) && includedNodeIds.has(edge.to_node), 95 + ); 96 + 97 + return { 98 + nodes: filteredNodes, 99 + edges: filteredEdges, 100 + }; 101 + } 102 + 103 + /** 104 + * Generate the Cytoscape stylesheet for decision graph visualization. 105 + * Styles nodes by type and status, edges by type and relationship. 106 + */ 107 + export function decisionStylesheet(): cytoscape.StylesheetJson { 108 + return [ 109 + // Base node styling 110 + { 111 + selector: 'node', 112 + style: { 113 + 'content': 'data(label)', 114 + 'text-valign': 'center', 115 + 'text-halign': 'center', 116 + 'text-wrap': 'wrap', 117 + 'font-size': 12, 118 + 'font-weight': 'normal', 119 + 'color': '#000', 120 + 'background-color': '#ccc', 121 + 'border-color': '#333', 122 + 'border-width': 2, 123 + 'padding': '10px', 124 + 'min-zoomed-font-size': 10, 125 + }, 126 + }, 127 + 128 + // Decision nodes: diamond shape, gold background 129 + { 130 + selector: 'node[type = "decision"]', 131 + style: { 132 + 'shape': 'diamond', 133 + 'background-color': '#FFD700', 134 + 'border-color': '#DAA520', 135 + }, 136 + }, 137 + 138 + // Option nodes: hexagon shape 139 + { 140 + selector: 'node[type = "option"]', 141 + style: { 142 + 'shape': 'hexagon', 143 + }, 144 + }, 145 + 146 + // Option chosen: green background 147 + { 148 + selector: 'node[type = "option"][status = "chosen"]', 149 + style: { 150 + 'background-color': '#32CD32', 151 + 'border-color': '#228B22', 152 + }, 153 + }, 154 + 155 + // Option rejected: gray background, dashed border 156 + { 157 + selector: 'node[type = "option"][status = "rejected"]', 158 + style: { 159 + 'background-color': '#666', 160 + 'border-color': '#333', 161 + 'border-style': 'dashed', 162 + }, 163 + }, 164 + 165 + // Option abandoned: muted red, dashed border 166 + { 167 + selector: 'node[type = "option"][status = "abandoned"]', 168 + style: { 169 + 'background-color': '#CD5C5C', 170 + 'border-color': '#8B3A3A', 171 + 'border-style': 'dashed', 172 + }, 173 + }, 174 + 175 + // Outcome nodes: ellipse shape 176 + { 177 + selector: 'node[type = "outcome"]', 178 + style: { 179 + 'shape': 'ellipse', 180 + }, 181 + }, 182 + 183 + // Outcome completed: green 184 + { 185 + selector: 'node[type = "outcome"][status = "completed"]', 186 + style: { 187 + 'background-color': '#32CD32', 188 + 'border-color': '#228B22', 189 + }, 190 + }, 191 + 192 + // Outcome active: blue 193 + { 194 + selector: 'node[type = "outcome"][status = "active"]', 195 + style: { 196 + 'background-color': '#4169E1', 197 + 'border-color': '#00008B', 198 + }, 199 + }, 200 + 201 + // Revisit nodes: triangle, orange 202 + { 203 + selector: 'node[type = "revisit"]', 204 + style: { 205 + 'shape': 'triangle', 206 + 'background-color': '#FFA500', 207 + 'border-color': '#FF8C00', 208 + }, 209 + }, 210 + 211 + // Selected node: highlighted border 212 + { 213 + selector: 'node:selected', 214 + style: { 215 + 'border-width': 3, 216 + 'border-color': '#FF0000', 217 + }, 218 + }, 219 + 220 + // Base edge styling 221 + { 222 + selector: 'edge', 223 + style: { 224 + 'line-color': '#999', 225 + 'target-arrow-color': '#999', 226 + 'target-arrow-shape': 'triangle', 227 + 'curve-style': 'straight', 228 + 'content': 'data(label)', 229 + 'font-size': 11, 230 + 'text-background-color': '#fff', 231 + 'text-background-padding': '3px', 232 + }, 233 + }, 234 + 235 + // Chosen edges: thicker, green 236 + { 237 + selector: 'edge[edgeType = "chosen"]', 238 + style: { 239 + 'line-color': '#32CD32', 240 + 'target-arrow-color': '#32CD32', 241 + 'width': 3, 242 + }, 243 + }, 244 + 245 + // Rejected edges: dashed, gray 246 + { 247 + selector: 'edge[edgeType = "rejected"]', 248 + style: { 249 + 'line-color': '#999', 250 + 'target-arrow-color': '#999', 251 + 'line-style': 'dashed', 252 + 'width': 1, 253 + }, 254 + }, 255 + 256 + // Leads-to edges: normal style 257 + { 258 + selector: 'edge[edgeType = "leadsto"]', 259 + style: { 260 + 'line-color': '#666', 261 + 'target-arrow-color': '#666', 262 + 'width': 2, 263 + }, 264 + }, 265 + 266 + // Depends-on edges: normal style 267 + { 268 + selector: 'edge[edgeType = "dependson"]', 269 + style: { 270 + 'line-color': '#666', 271 + 'target-arrow-color': '#666', 272 + 'width': 2, 273 + }, 274 + }, 275 + ]; 276 + }