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