An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
at new-directions 333 lines 7.9 kB view raw
1<script lang="ts"> 2 /** 3 * GraphNodeCard component. 4 * Displays a graph node's full information in a card layout. 5 * Shows title, status, priority, agent assignment, blocked reason, 6 * description, metadata (acceptance criteria), and timestamps. 7 */ 8 9 import type { GraphNode } from '../types'; 10 import StatusBadge from './StatusBadge.svelte'; 11 import PriorityBadge from './PriorityBadge.svelte'; 12 import { formatDate } from '../lib/date-formatting'; 13 14 type Props = { 15 node: GraphNode; 16 onclick?: () => void; 17 }; 18 19 let { node, onclick }: Props = $props(); 20 21 let showFullDescription = $state(false); 22 23 /** 24 * Format node type for display (lowercase to Title Case). 25 */ 26 function formatNodeType(nodeType: string): string { 27 return nodeType.charAt(0).toUpperCase() + nodeType.slice(1); 28 } 29 30 /** 31 * Truncate description to 2-3 lines (approximately 150 characters). 32 */ 33 function truncateDescription(text: string): string { 34 const maxLength = 150; 35 if (text.length > maxLength) { 36 return text.substring(0, maxLength).trim() + '...'; 37 } 38 return text; 39 } 40 41 /** 42 * Format a relative timestamp (e.g., "2 hours ago" or "Feb 11, 2026"). 43 */ 44 function formatTimestamp(dateStr: string): string { 45 try { 46 const date = new Date(dateStr); 47 const now = new Date(); 48 const diffMs = now.getTime() - date.getTime(); 49 const diffMins = Math.floor(diffMs / 60000); 50 const diffHours = Math.floor(diffMs / 3600000); 51 const diffDays = Math.floor(diffMs / 86400000); 52 53 if (diffMins < 1) return 'just now'; 54 if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`; 55 if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`; 56 if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`; 57 return formatDate(dateStr); 58 } catch { 59 return formatDate(dateStr); 60 } 61 } 62 63 const nodeTypeFormatted = $derived(formatNodeType(node.node_type)); 64 const descriptionDisplay = $derived( 65 showFullDescription ? node.description : truncateDescription(node.description) 66 ); 67 const shouldShowMoreToggle = $derived(node.description.length > 150); 68 const createdDisplay = $derived(formatTimestamp(node.created_at)); 69</script> 70 71<div class="card" role="button" tabindex="0" {onclick} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onclick?.(); } }}> 72 <div class="card-header"> 73 <h3 class="title">{node.title}</h3> 74 <div class="badges"> 75 <StatusBadge status={node.status} /> 76 <PriorityBadge priority={node.priority} /> 77 </div> 78 </div> 79 80 <div class="meta-row"> 81 <span class="node-type">{nodeTypeFormatted}</span> 82 {#if node.assigned_to} 83 <span class="agent-chip">{node.assigned_to}</span> 84 {/if} 85 </div> 86 87 {#if node.blocked_reason} 88 <div class="blocked-reason"> 89 <strong>Blocked:</strong> {node.blocked_reason} 90 </div> 91 {/if} 92 93 {#if node.description} 94 <div class="description"> 95 <p>{descriptionDisplay}</p> 96 {#if shouldShowMoreToggle} 97 <button 98 class="show-more-btn" 99 onclick={() => { 100 showFullDescription = !showFullDescription; 101 }} 102 > 103 {showFullDescription ? 'Show Less' : 'Show More'} 104 </button> 105 {/if} 106 </div> 107 {/if} 108 109 {#if node.metadata && Object.keys(node.metadata).length > 0} 110 <div class="metadata-section"> 111 {#if node.metadata.acceptance_criteria} 112 <div class="acceptance-criteria"> 113 <h4>Acceptance Criteria</h4> 114 <ul> 115 {#each node.metadata.acceptance_criteria.split('\n').filter((line) => line.trim()) as criterion} 116 <li> 117 <input type="checkbox" disabled /> 118 {criterion.trim().replace(/^[-*]\s*/, '')} 119 </li> 120 {/each} 121 </ul> 122 </div> 123 {/if} 124 125 {#if Object.keys(node.metadata).some((k) => k !== 'acceptance_criteria')} 126 <div class="other-metadata"> 127 {#each Object.entries(node.metadata) as [key, value]} 128 {#if key !== 'acceptance_criteria'} 129 <div class="metadata-pair"> 130 <strong>{key}:</strong> 131 <span>{value}</span> 132 </div> 133 {/if} 134 {/each} 135 </div> 136 {/if} 137 </div> 138 {/if} 139 140 <div class="timestamps"> 141 <div class="timestamp"> 142 <span class="label">Created:</span> 143 <span class="value">{createdDisplay}</span> 144 </div> 145 {#if node.started_at} 146 <div class="timestamp"> 147 <span class="label">Started:</span> 148 <span class="value">{formatTimestamp(node.started_at)}</span> 149 </div> 150 {/if} 151 {#if node.completed_at} 152 <div class="timestamp"> 153 <span class="label">Completed:</span> 154 <span class="value">{formatTimestamp(node.completed_at)}</span> 155 </div> 156 {/if} 157 </div> 158</div> 159 160<style> 161 .card { 162 background: white; 163 border: 1px solid #e5e7eb; 164 border-radius: 0.5rem; 165 padding: 1.5rem; 166 cursor: pointer; 167 transition: all 0.2s ease; 168 } 169 170 .card:hover { 171 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 172 border-color: #d1d5db; 173 } 174 175 .card-header { 176 display: flex; 177 justify-content: space-between; 178 align-items: flex-start; 179 margin-bottom: 1rem; 180 gap: 1rem; 181 } 182 183 .title { 184 margin: 0; 185 font-size: 1.25rem; 186 font-weight: 600; 187 color: #111827; 188 flex: 1; 189 } 190 191 .badges { 192 display: flex; 193 gap: 0.5rem; 194 flex-wrap: wrap; 195 justify-content: flex-end; 196 } 197 198 .meta-row { 199 display: flex; 200 gap: 1rem; 201 align-items: center; 202 margin-bottom: 1rem; 203 font-size: 0.875rem; 204 } 205 206 .node-type { 207 color: #6b7280; 208 font-size: 0.8125rem; 209 text-transform: uppercase; 210 letter-spacing: 0.05em; 211 } 212 213 .agent-chip { 214 background-color: #f0f4f8; 215 border: 1px solid #cbd5e1; 216 padding: 0.25rem 0.75rem; 217 border-radius: 0.375rem; 218 font-size: 0.875rem; 219 font-weight: 500; 220 color: #334155; 221 } 222 223 .blocked-reason { 224 background-color: #fee2e2; 225 border-left: 4px solid #ef4444; 226 padding: 0.75rem; 227 margin-bottom: 1rem; 228 border-radius: 0.375rem; 229 color: #7f1d1d; 230 font-size: 0.875rem; 231 } 232 233 .description { 234 margin-bottom: 1rem; 235 } 236 237 .description p { 238 margin: 0 0 0.5rem 0; 239 color: #374151; 240 line-height: 1.5; 241 font-size: 0.875rem; 242 } 243 244 .show-more-btn { 245 background: none; 246 border: none; 247 color: #3b82f6; 248 cursor: pointer; 249 font-size: 0.875rem; 250 font-weight: 500; 251 padding: 0; 252 text-decoration: underline; 253 } 254 255 .show-more-btn:hover { 256 color: #2563eb; 257 } 258 259 .metadata-section { 260 margin-bottom: 1rem; 261 border-top: 1px solid #e5e7eb; 262 padding-top: 1rem; 263 } 264 265 .acceptance-criteria h4 { 266 margin: 0 0 0.5rem 0; 267 font-size: 0.875rem; 268 font-weight: 600; 269 color: #374151; 270 } 271 272 .acceptance-criteria ul { 273 margin: 0; 274 padding-left: 1.5rem; 275 list-style: none; 276 } 277 278 .acceptance-criteria li { 279 display: flex; 280 align-items: center; 281 gap: 0.5rem; 282 margin-bottom: 0.5rem; 283 font-size: 0.875rem; 284 color: #374151; 285 } 286 287 .acceptance-criteria input { 288 cursor: default; 289 width: 1rem; 290 height: 1rem; 291 } 292 293 .other-metadata { 294 margin-top: 1rem; 295 } 296 297 .metadata-pair { 298 display: flex; 299 gap: 0.5rem; 300 margin-bottom: 0.5rem; 301 font-size: 0.875rem; 302 color: #374151; 303 } 304 305 .metadata-pair strong { 306 min-width: 8rem; 307 color: #6b7280; 308 } 309 310 .timestamps { 311 border-top: 1px solid #e5e7eb; 312 padding-top: 1rem; 313 display: flex; 314 flex-direction: column; 315 gap: 0.5rem; 316 font-size: 0.8125rem; 317 color: #6b7280; 318 } 319 320 .timestamp { 321 display: flex; 322 gap: 0.75rem; 323 } 324 325 .timestamp .label { 326 font-weight: 500; 327 min-width: 6rem; 328 } 329 330 .timestamp .value { 331 color: #374151; 332 } 333</style>