An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
at new-directions 452 lines 12 kB view raw
1<script lang="ts"> 2 /** 3 * TaskTree view component. 4 * Displays a hierarchical tree of goals, tasks, and subtasks with: 5 * - Three-panel layout (tree | detail | ready tasks) 6 * - Expandable/collapsible nodes 7 * - Status and dependency visualization 8 * - Task statistics (progress bar) 9 */ 10 11 import { getCurrentRoute } from '../router.svelte'; 12 import { graphState, loadGoalTree, loadReadyTasks, selectNode, clearSelection } from '../stores/graph.svelte'; 13 import { apiClient } from '../api'; 14 import { buildTree, countTaskStats, type TreeNode } from '../lib/tree'; 15 import type { GraphNode } from '../types'; 16 import TreeNodeRow from '../components/TreeNodeRow.svelte'; 17 import GraphNodeCard from '../components/GraphNodeCard.svelte'; 18 import LoadingSpinner from '../components/LoadingSpinner.svelte'; 19 import ErrorMessage from '../components/ErrorMessage.svelte'; 20 21 let nextTask: GraphNode | null = $state(null); 22 let nodeExpandedState: Record<string, boolean> = $state({}); 23 24 // Get goalId from route params 25 const currentRoute = $derived(getCurrentRoute()); 26 const goalId = $derived(currentRoute.params.goalId || ''); 27 28 // Load goal tree and ready tasks when goalId changes 29 $effect(() => { 30 if (goalId) { 31 // Trigger async loads 32 loadGoalTree(goalId); 33 loadReadyTasks(goalId); 34 // Load next task directly via API 35 loadNextTask(); 36 } 37 }); 38 39 async function loadNextTask(): Promise<void> { 40 if (!goalId) return; 41 try { 42 nextTask = await apiClient.getNextTask(goalId); 43 } catch (error) { 44 // Silently fail on next task (optional feature) 45 console.error('Failed to load next task:', error); 46 } 47 } 48 49 // Build the tree structure from flat goal tree 50 const treeRoot = $derived( 51 graphState.goalTree ? buildTree(graphState.goalTree) : null 52 ); 53 54 // Compute task statistics 55 const taskStats = $derived(treeRoot ? countTaskStats(treeRoot) : null); 56 57 // Calculate progress percentage 58 const progressPercent = $derived( 59 taskStats && taskStats.total > 0 ? (taskStats.completed / taskStats.total) * 100 : 0 60 ); 61 62 // Update node expanded state in tree 63 function toggleNodeExpanded(nodeId: string): void { 64 if (nodeExpandedState[nodeId] === undefined) { 65 nodeExpandedState[nodeId] = false; // Start with expanded=true from buildTree 66 } else { 67 nodeExpandedState[nodeId] = !nodeExpandedState[nodeId]; 68 } 69 // Trigger reactivity by reassigning the object 70 nodeExpandedState = nodeExpandedState; 71 } 72 73 /** 74 * Apply expanded state to tree recursively. 75 */ 76 function applyExpandedState(node: TreeNode): TreeNode { 77 return { 78 ...node, 79 expanded: nodeExpandedState[node.node.id] !== false, // Default to true 80 children: node.children.map((child) => applyExpandedState(child)), 81 }; 82 } 83 84 // Apply expanded state to rendered tree 85 const displayTree = $derived(treeRoot ? applyExpandedState(treeRoot) : null); 86</script> 87 88<div class="task-tree-container"> 89 {#if graphState.error} 90 <ErrorMessage message={graphState.error} /> 91 {:else if graphState.loading} 92 <LoadingSpinner /> 93 {:else if !displayTree} 94 <div class="empty-state"> 95 <p>No task tree available for this goal.</p> 96 </div> 97 {:else} 98 <div class="layout"> 99 <!-- Left Panel: Task Tree --> 100 <div class="left-panel"> 101 <div class="tree-header"> 102 <h2>Task Hierarchy</h2> 103 {#if taskStats} 104 <div class="progress-section"> 105 <div class="progress-bar"> 106 <div class="progress-fill" style="width: {progressPercent}%"></div> 107 </div> 108 <div class="progress-text"> 109 {taskStats.completed} / {taskStats.total} completed 110 </div> 111 </div> 112 {/if} 113 </div> 114 115 <div class="tree-content"> 116 <TreeNodeRow 117 treeNode={displayTree} 118 depth={0} 119 selectedNodeId={graphState.selectedNodeId} 120 onSelect={selectNode} 121 onToggleExpanded={toggleNodeExpanded} 122 /> 123 </div> 124 </div> 125 126 <!-- Right Panel: Node Detail --> 127 <div class="right-panel"> 128 {#if graphState.selectedNodeDetail} 129 <div class="detail-header"> 130 <h3>Node Details</h3> 131 <button class="close-btn" onclick={() => clearSelection()}>✕</button> 132 </div> 133 <div class="detail-content"> 134 <GraphNodeCard node={graphState.selectedNodeDetail.node} /> 135 136 {#if graphState.selectedNodeDetail.outgoing_edges} 137 {@const dependsOnEdges = graphState.selectedNodeDetail.outgoing_edges.filter(([edge]) => edge.edge_type === 'dependson')} 138 {#if dependsOnEdges.length > 0} 139 <div class="dependencies-section"> 140 <h4>Dependencies</h4> 141 <ul class="dependency-list"> 142 {#each dependsOnEdges as [edge, depNode] (edge.id)} 143 <li> 144 <button class="link-btn" onclick={() => selectNode(depNode.id)}> 145 {depNode.title} ({depNode.id}) 146 </button> 147 </li> 148 {/each} 149 </ul> 150 </div> 151 {/if} 152 {/if} 153 154 {#if graphState.selectedNodeDetail.incoming_edges && graphState.selectedNodeDetail.incoming_edges.length > 0} 155 <div class="edges-section"> 156 <h4>Incoming Edges</h4> 157 <ul class="edges-list"> 158 {#each graphState.selectedNodeDetail.incoming_edges as [edge, sourceNode] (edge.id)} 159 <li>{edge.edge_type}: {sourceNode.title} ({sourceNode.id})</li> 160 {/each} 161 </ul> 162 </div> 163 {/if} 164 165 {#if graphState.selectedNodeDetail.outgoing_edges && graphState.selectedNodeDetail.outgoing_edges.length > 0} 166 <div class="edges-section"> 167 <h4>Outgoing Edges</h4> 168 <ul class="edges-list"> 169 {#each graphState.selectedNodeDetail.outgoing_edges as [edge, targetNode] (edge.id)} 170 <li>{edge.edge_type}: {targetNode.title} ({targetNode.id})</li> 171 {/each} 172 </ul> 173 </div> 174 {/if} 175 </div> 176 {:else} 177 <div class="empty-detail"> 178 <p>Select a node to view details</p> 179 </div> 180 {/if} 181 </div> 182 </div> 183 184 <!-- Bottom Panel: Ready Tasks --> 185 {#if graphState.readyTasks.length > 0} 186 <div class="bottom-panel"> 187 <h3>Ready Tasks</h3> 188 <div class="ready-tasks-list"> 189 {#each graphState.readyTasks as task (task.id)} 190 <button 191 class="ready-task-item" 192 class:next-task={nextTask && task.id === nextTask.id} 193 onclick={() => selectNode(task.id)} 194 > 195 <div class="task-title">{task.title}</div> 196 {#if nextTask && task.id === nextTask.id} 197 <span class="next-badge">Next</span> 198 {/if} 199 </button> 200 {/each} 201 </div> 202 </div> 203 {/if} 204 {/if} 205</div> 206 207<style> 208 .task-tree-container { 209 display: flex; 210 flex-direction: column; 211 height: 100%; 212 width: 100%; 213 background: white; 214 overflow: hidden; 215 } 216 217 .empty-state { 218 display: flex; 219 align-items: center; 220 justify-content: center; 221 height: 100%; 222 color: #6b7280; 223 } 224 225 .layout { 226 display: flex; 227 flex: 1; 228 gap: 1rem; 229 padding: 1rem; 230 overflow: hidden; 231 } 232 233 /* Left Panel */ 234 .left-panel { 235 flex: 0 0 50%; 236 display: flex; 237 flex-direction: column; 238 border: 1px solid #e5e7eb; 239 border-radius: 0.5rem; 240 background: white; 241 overflow: hidden; 242 } 243 244 .tree-header { 245 padding: 1rem; 246 border-bottom: 1px solid #e5e7eb; 247 background: #f9fafb; 248 } 249 250 .tree-header h2 { 251 margin: 0 0 1rem 0; 252 font-size: 1.125rem; 253 font-weight: 600; 254 color: #111827; 255 } 256 257 .progress-section { 258 display: flex; 259 flex-direction: column; 260 gap: 0.5rem; 261 } 262 263 .progress-bar { 264 height: 0.5rem; 265 background: #e5e7eb; 266 border-radius: 9999px; 267 overflow: hidden; 268 } 269 270 .progress-fill { 271 height: 100%; 272 background: #10b981; 273 transition: width 0.3s ease; 274 } 275 276 .progress-text { 277 font-size: 0.75rem; 278 color: #6b7280; 279 } 280 281 .tree-content { 282 flex: 1; 283 overflow-y: auto; 284 font-family: 'Monaco', 'Menlo', 'Consolas', monospace; 285 } 286 287 /* Right Panel */ 288 .right-panel { 289 flex: 0 0 40%; 290 display: flex; 291 flex-direction: column; 292 border: 1px solid #e5e7eb; 293 border-radius: 0.5rem; 294 background: white; 295 overflow: hidden; 296 } 297 298 .detail-header { 299 display: flex; 300 justify-content: space-between; 301 align-items: center; 302 padding: 1rem; 303 border-bottom: 1px solid #e5e7eb; 304 background: #f9fafb; 305 } 306 307 .detail-header h3 { 308 margin: 0; 309 font-size: 1.125rem; 310 font-weight: 600; 311 color: #111827; 312 } 313 314 .close-btn { 315 background: none; 316 border: none; 317 font-size: 1.5rem; 318 cursor: pointer; 319 color: #6b7280; 320 padding: 0; 321 width: 2rem; 322 height: 2rem; 323 display: flex; 324 align-items: center; 325 justify-content: center; 326 } 327 328 .close-btn:hover { 329 color: #111827; 330 } 331 332 .detail-content { 333 flex: 1; 334 overflow-y: auto; 335 padding: 1rem; 336 } 337 338 .empty-detail { 339 display: flex; 340 align-items: center; 341 justify-content: center; 342 height: 100%; 343 color: #6b7280; 344 } 345 346 .dependencies-section, 347 .edges-section { 348 margin-top: 1.5rem; 349 padding-top: 1rem; 350 border-top: 1px solid #e5e7eb; 351 } 352 353 .dependencies-section h4, 354 .edges-section h4 { 355 margin: 0 0 0.75rem 0; 356 font-size: 0.875rem; 357 font-weight: 600; 358 color: #374151; 359 } 360 361 .dependency-list, 362 .edges-list { 363 margin: 0; 364 padding-left: 1.5rem; 365 list-style: none; 366 } 367 368 .dependency-list li, 369 .edges-list li { 370 font-size: 0.875rem; 371 color: #374151; 372 margin-bottom: 0.5rem; 373 } 374 375 .link-btn { 376 background: none; 377 border: none; 378 color: #3b82f6; 379 cursor: pointer; 380 text-decoration: underline; 381 font-size: 0.875rem; 382 padding: 0; 383 } 384 385 .link-btn:hover { 386 color: #2563eb; 387 } 388 389 /* Bottom Panel */ 390 .bottom-panel { 391 padding: 1rem; 392 border-top: 1px solid #e5e7eb; 393 background: #f9fafb; 394 max-height: 150px; 395 overflow-y: auto; 396 } 397 398 .bottom-panel h3 { 399 margin: 0 0 0.75rem 0; 400 font-size: 1rem; 401 font-weight: 600; 402 color: #111827; 403 } 404 405 .ready-tasks-list { 406 display: flex; 407 gap: 0.75rem; 408 flex-wrap: wrap; 409 } 410 411 .ready-task-item { 412 display: flex; 413 align-items: center; 414 gap: 0.5rem; 415 background: white; 416 border: 1px solid #cbd5e1; 417 padding: 0.5rem 0.75rem; 418 border-radius: 0.375rem; 419 cursor: pointer; 420 font-size: 0.875rem; 421 font-weight: 500; 422 color: #111827; 423 transition: all 0.2s ease; 424 } 425 426 .ready-task-item:hover { 427 background: #f0f9ff; 428 border-color: #3b82f6; 429 } 430 431 .ready-task-item.next-task { 432 background: #fef3c7; 433 border-color: #fbbf24; 434 font-weight: 600; 435 } 436 437 .task-title { 438 max-width: 200px; 439 overflow: hidden; 440 text-overflow: ellipsis; 441 white-space: nowrap; 442 } 443 444 .next-badge { 445 background: #fbbf24; 446 color: #78350f; 447 padding: 0.125rem 0.5rem; 448 border-radius: 0.25rem; 449 font-size: 0.75rem; 450 font-weight: 600; 451 } 452</style>