An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
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>