tanuki-telemetry 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/Dockerfile +22 -0
  2. package/bin/tanuki.mjs +251 -0
  3. package/frontend/eslint.config.js +23 -0
  4. package/frontend/index.html +13 -0
  5. package/frontend/package.json +39 -0
  6. package/frontend/src/App.tsx +232 -0
  7. package/frontend/src/assets/hero.png +0 -0
  8. package/frontend/src/assets/react.svg +1 -0
  9. package/frontend/src/assets/vite.svg +1 -0
  10. package/frontend/src/components/ArtifactsPanel.tsx +429 -0
  11. package/frontend/src/components/ChildStreams.tsx +176 -0
  12. package/frontend/src/components/CoordinatorPage.tsx +317 -0
  13. package/frontend/src/components/Header.tsx +108 -0
  14. package/frontend/src/components/InsightsPanel.tsx +142 -0
  15. package/frontend/src/components/IterationsTable.tsx +98 -0
  16. package/frontend/src/components/KnowledgePage.tsx +308 -0
  17. package/frontend/src/components/LoginPage.tsx +55 -0
  18. package/frontend/src/components/PlanProgress.tsx +163 -0
  19. package/frontend/src/components/QualityReport.tsx +276 -0
  20. package/frontend/src/components/ScreenshotUpload.tsx +117 -0
  21. package/frontend/src/components/ScreenshotsGrid.tsx +266 -0
  22. package/frontend/src/components/SessionDetail.tsx +265 -0
  23. package/frontend/src/components/SessionList.tsx +234 -0
  24. package/frontend/src/components/SettingsPage.tsx +213 -0
  25. package/frontend/src/components/StreamComms.tsx +228 -0
  26. package/frontend/src/components/TanukiLogo.tsx +16 -0
  27. package/frontend/src/components/Timeline.tsx +416 -0
  28. package/frontend/src/components/WalkthroughPage.tsx +458 -0
  29. package/frontend/src/hooks/useApi.ts +81 -0
  30. package/frontend/src/hooks/useAuth.ts +54 -0
  31. package/frontend/src/hooks/useKnowledge.ts +33 -0
  32. package/frontend/src/hooks/useWebSocket.ts +95 -0
  33. package/frontend/src/index.css +66 -0
  34. package/frontend/src/lib/api.ts +15 -0
  35. package/frontend/src/lib/utils.ts +58 -0
  36. package/frontend/src/main.tsx +10 -0
  37. package/frontend/src/types.ts +181 -0
  38. package/frontend/tsconfig.app.json +32 -0
  39. package/frontend/tsconfig.json +7 -0
  40. package/frontend/vite.config.ts +25 -0
  41. package/install.sh +87 -0
  42. package/package.json +63 -0
  43. package/src/api-keys.ts +97 -0
  44. package/src/auth.ts +165 -0
  45. package/src/coordinator.ts +136 -0
  46. package/src/dashboard-server.ts +5 -0
  47. package/src/dashboard.ts +826 -0
  48. package/src/db.ts +1009 -0
  49. package/src/index.ts +20 -0
  50. package/src/middleware.ts +76 -0
  51. package/src/tools.ts +864 -0
  52. package/src/types-shim.d.ts +18 -0
  53. package/src/types.ts +171 -0
  54. package/tsconfig.json +19 -0
@@ -0,0 +1,429 @@
1
+ import { useState, useCallback, useRef, useEffect, type ReactNode } from "react"
2
+ import gsap from "gsap"
3
+ import { FileText, Hash, ChevronRight, Presentation, Equal, Braces, ArrowRight, File } from "lucide-react"
4
+ import type { Artifact } from "@/types"
5
+ import { timeAgo } from "@/lib/utils"
6
+
7
+ interface Props {
8
+ artifacts: Artifact[]
9
+ }
10
+
11
+ const TYPE_ICONS: Record<string, ReactNode> = {
12
+ template: <FileText size={10} />,
13
+ rubric: <Hash size={10} />,
14
+ report: <ChevronRight size={10} />,
15
+ pptx: <Presentation size={10} />,
16
+ summary: <Equal size={10} />,
17
+ config: <Braces size={10} />,
18
+ output: <ArrowRight size={10} />,
19
+ }
20
+
21
+ const LINE_LIMIT = 200
22
+
23
+ function formatSize(bytes: number | null): string {
24
+ if (bytes == null) return "—"
25
+ if (bytes < 1024) return `${bytes} B`
26
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
27
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
28
+ }
29
+
30
+ function artifactUrl(id: number): string {
31
+ return `/api/artifacts/by-id/${id}`
32
+ }
33
+
34
+ function isTextViewable(a: Artifact): boolean {
35
+ const mime = a.mime_type || ""
36
+ const ext = a.file_path?.split(".").pop()?.toLowerCase() || ""
37
+ return (
38
+ mime.startsWith("text/") ||
39
+ mime === "application/json" ||
40
+ ["md", "json", "txt", "csv", "log", "yaml", "yml", "toml", "xml", "html", "css", "js", "ts", "tsx", "jsx", "sh", "py", "rb", "sql"].includes(ext) ||
41
+ ["report", "summary"].includes(a.artifact_type)
42
+ )
43
+ }
44
+
45
+ function isImage(a: Artifact): boolean {
46
+ return a.mime_type?.startsWith("image/") || false
47
+ }
48
+
49
+ function getContentType(a: Artifact): "markdown" | "json" | "text" {
50
+ const mime = a.mime_type || ""
51
+ const ext = a.file_path?.split(".").pop()?.toLowerCase() || ""
52
+
53
+ if (mime === "application/json" || ext === "json") return "json"
54
+ if (mime === "text/markdown" || ext === "md" || ["report", "summary"].includes(a.artifact_type)) return "markdown"
55
+ return "text"
56
+ }
57
+
58
+ // Minimal markdown to HTML — handles the common cases
59
+ function renderMarkdown(md: string): string {
60
+ let html = md
61
+ // Escape HTML
62
+ .replace(/&/g, "&amp;")
63
+ .replace(/</g, "&lt;")
64
+ .replace(/>/g, "&gt;")
65
+ // Code blocks (fenced)
66
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, lang, code) =>
67
+ `<pre class="artifact-code-block" data-lang="${lang}"><code>${code}</code></pre>`
68
+ )
69
+ // Inline code
70
+ html = html.replace(/`([^`]+)`/g, '<code class="artifact-inline-code">$1</code>')
71
+ // Headers
72
+ html = html.replace(/^#### (.+)$/gm, '<h4 class="artifact-h4">$1</h4>')
73
+ html = html.replace(/^### (.+)$/gm, '<h3 class="artifact-h3">$1</h3>')
74
+ html = html.replace(/^## (.+)$/gm, '<h2 class="artifact-h2">$1</h2>')
75
+ html = html.replace(/^# (.+)$/gm, '<h1 class="artifact-h1">$1</h1>')
76
+ // Bold & italic
77
+ html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
78
+ html = html.replace(/\*(.+?)\*/g, "<em>$1</em>")
79
+ // Unordered lists
80
+ html = html.replace(/^- (.+)$/gm, '<li class="artifact-li">$1</li>')
81
+ // Horizontal rules
82
+ html = html.replace(/^---$/gm, '<hr class="artifact-hr" />')
83
+ // Links
84
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="artifact-link">$1</a>')
85
+ // Paragraphs (double newlines)
86
+ html = html.replace(/\n\n/g, "</p><p>")
87
+ // Single newlines to <br> (but not inside <pre>)
88
+ html = html.replace(/(?<!<\/pre>)\n(?!<)/g, "<br>")
89
+ return `<p>${html}</p>`
90
+ }
91
+
92
+ // Collapsible JSON viewer
93
+ function JsonViewer({ data, depth = 0 }: { data: unknown; depth?: number }) {
94
+ const [collapsed, setCollapsed] = useState(depth > 2)
95
+
96
+ if (data === null) return <span className="text-text-dim">null</span>
97
+ if (typeof data === "boolean") return <span className="text-warning">{String(data)}</span>
98
+ if (typeof data === "number") return <span className="text-info">{data}</span>
99
+ if (typeof data === "string") return <span className="text-success">"{data}"</span>
100
+
101
+ if (Array.isArray(data)) {
102
+ if (data.length === 0) return <span className="text-text-dim">[]</span>
103
+ return (
104
+ <span>
105
+ <button
106
+ onClick={(e) => { e.stopPropagation(); setCollapsed(!collapsed) }}
107
+ className="text-text-dim hover:text-text cursor-pointer"
108
+ >
109
+ {collapsed ? "▶" : "▼"} [{data.length}]
110
+ </button>
111
+ {!collapsed && (
112
+ <div style={{ paddingLeft: 16 }}>
113
+ {data.map((item, i) => (
114
+ <div key={i}>
115
+ <JsonViewer data={item} depth={depth + 1} />
116
+ {i < data.length - 1 && <span className="text-text-dim">,</span>}
117
+ </div>
118
+ ))}
119
+ </div>
120
+ )}
121
+ </span>
122
+ )
123
+ }
124
+
125
+ if (typeof data === "object") {
126
+ const entries = Object.entries(data as Record<string, unknown>)
127
+ if (entries.length === 0) return <span className="text-text-dim">{"{}"}</span>
128
+ return (
129
+ <span>
130
+ <button
131
+ onClick={(e) => { e.stopPropagation(); setCollapsed(!collapsed) }}
132
+ className="text-text-dim hover:text-text cursor-pointer"
133
+ >
134
+ {collapsed ? "▶" : "▼"} {"{"}
135
+ {collapsed && <span className="text-text-dim">…{entries.length} keys{"}"}</span>}
136
+ </button>
137
+ {!collapsed && (
138
+ <div style={{ paddingLeft: 16 }}>
139
+ {entries.map(([key, val], i) => (
140
+ <div key={key}>
141
+ <span className="text-accent">"{key}"</span>
142
+ <span className="text-text-dim">: </span>
143
+ <JsonViewer data={val} depth={depth + 1} />
144
+ {i < entries.length - 1 && <span className="text-text-dim">,</span>}
145
+ </div>
146
+ ))}
147
+ </div>
148
+ )}
149
+ {!collapsed && <span>{"}"}</span>}
150
+ </span>
151
+ )
152
+ }
153
+
154
+ return <span>{String(data)}</span>
155
+ }
156
+
157
+ // Text content with line limiting
158
+ function TextContent({ text, type }: { text: string; type: "markdown" | "json" | "text" }) {
159
+ const [showAll, setShowAll] = useState(false)
160
+ const lines = text.split("\n")
161
+ const truncated = lines.length > LINE_LIMIT && !showAll
162
+ const displayText = truncated ? lines.slice(0, LINE_LIMIT).join("\n") : text
163
+
164
+ if (type === "json") {
165
+ try {
166
+ const parsed = JSON.parse(text)
167
+ return (
168
+ <div className="text-xs font-mono p-3 bg-bg rounded overflow-x-auto max-h-[500px] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
169
+ <JsonViewer data={parsed} />
170
+ </div>
171
+ )
172
+ } catch {
173
+ // Fall through to plain text if JSON is invalid
174
+ }
175
+ }
176
+
177
+ if (type === "markdown") {
178
+ return (
179
+ <div onClick={(e) => e.stopPropagation()}>
180
+ <div
181
+ className="artifact-markdown text-xs p-3 bg-bg rounded overflow-x-auto max-h-[500px] overflow-y-auto"
182
+ dangerouslySetInnerHTML={{ __html: renderMarkdown(displayText) }}
183
+ />
184
+ {truncated && (
185
+ <button
186
+ onClick={() => setShowAll(true)}
187
+ className="text-[10px] text-accent hover:underline mt-1 px-3 cursor-pointer"
188
+ >
189
+ Show all {lines.length} lines…
190
+ </button>
191
+ )}
192
+ </div>
193
+ )
194
+ }
195
+
196
+ // Plain text
197
+ return (
198
+ <div onClick={(e) => e.stopPropagation()}>
199
+ <pre className="text-xs font-mono p-3 bg-bg rounded overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words text-text">
200
+ {displayText}
201
+ </pre>
202
+ {truncated && (
203
+ <button
204
+ onClick={() => setShowAll(true)}
205
+ className="text-[10px] text-accent hover:underline mt-1 px-3 cursor-pointer"
206
+ >
207
+ Show all {lines.length} lines…
208
+ </button>
209
+ )}
210
+ </div>
211
+ )
212
+ }
213
+
214
+ // Image lightbox modal
215
+ function ImageLightbox({ src, alt, onClose }: { src: string; alt: string; onClose: () => void }) {
216
+ const overlayRef = useRef<HTMLDivElement>(null)
217
+
218
+ useEffect(() => {
219
+ const handleKey = (e: KeyboardEvent) => {
220
+ if (e.key === "Escape") onClose()
221
+ }
222
+ document.addEventListener("keydown", handleKey)
223
+ return () => document.removeEventListener("keydown", handleKey)
224
+ }, [onClose])
225
+
226
+ return (
227
+ <div
228
+ ref={overlayRef}
229
+ className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-8 cursor-pointer"
230
+ onClick={(e) => { if (e.target === overlayRef.current) onClose() }}
231
+ >
232
+ <div className="relative max-w-[90vw] max-h-[90vh]">
233
+ <button
234
+ onClick={onClose}
235
+ className="absolute -top-8 right-0 text-white/70 hover:text-white text-sm cursor-pointer"
236
+ >
237
+ ESC to close
238
+ </button>
239
+ <img src={src} alt={alt} className="max-w-full max-h-[85vh] object-contain rounded" />
240
+ </div>
241
+ </div>
242
+ )
243
+ }
244
+
245
+ // Individual artifact row with expand/collapse
246
+ function ArtifactRow({ artifact }: { artifact: Artifact }) {
247
+ const [expanded, setExpanded] = useState(false)
248
+ const [content, setContent] = useState<string | null>(null)
249
+ const [loading, setLoading] = useState(false)
250
+ const [error, setError] = useState<string | null>(null)
251
+ const [lightbox, setLightbox] = useState(false)
252
+
253
+ const viewable = isTextViewable(artifact)
254
+ const image = isImage(artifact)
255
+ const contentType = viewable ? getContentType(artifact) : null
256
+
257
+ const handleToggle = useCallback(() => {
258
+ if (image) {
259
+ setLightbox(true)
260
+ return
261
+ }
262
+ if (!viewable) return
263
+
264
+ const next = !expanded
265
+ setExpanded(next)
266
+
267
+ if (next && content === null && !loading) {
268
+ setLoading(true)
269
+ setError(null)
270
+ fetch(artifactUrl(artifact.id))
271
+ .then((res) => {
272
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
273
+ return res.text()
274
+ })
275
+ .then((text) => {
276
+ setContent(text)
277
+ setLoading(false)
278
+ })
279
+ .catch((err) => {
280
+ setError(err.message)
281
+ setLoading(false)
282
+ })
283
+ }
284
+ }, [expanded, content, loading, artifact.id, viewable, image])
285
+
286
+ const clickable = viewable || image
287
+
288
+ return (
289
+ <>
290
+ <div
291
+ data-artifact
292
+ className={`text-xs py-1.5 px-2 bg-bg transition-colors group ${clickable ? "hover:bg-bg-elevated cursor-pointer" : ""} ${expanded ? "bg-bg-elevated" : ""}`}
293
+ onClick={handleToggle}
294
+ >
295
+ <div className="flex items-center gap-3">
296
+ {/* Expand indicator for viewable content */}
297
+ {viewable && (
298
+ <span className="text-text-dim text-[10px] w-3 flex-shrink-0">
299
+ {expanded ? "▼" : "▶"}
300
+ </span>
301
+ )}
302
+
303
+ {/* Inline thumbnail for images */}
304
+ {image && (
305
+ <img
306
+ src={artifactUrl(artifact.id)}
307
+ alt={artifact.description}
308
+ className="w-8 h-8 object-cover rounded border border-border flex-shrink-0"
309
+ loading="lazy"
310
+ />
311
+ )}
312
+
313
+ <div className="flex-1 min-w-0">
314
+ <div className="text-text truncate">{artifact.description}</div>
315
+ <div className="text-[10px] text-text-dim">
316
+ {formatSize(artifact.file_size)}
317
+ {artifact.mime_type && <span className="ml-2">{artifact.mime_type}</span>}
318
+ <span className="ml-2">{timeAgo(artifact.timestamp)}</span>
319
+ {image && <span className="ml-2 text-info">click to enlarge</span>}
320
+ </div>
321
+ </div>
322
+
323
+ <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
324
+ {artifact.mime_type === "application/pdf" && (
325
+ <a
326
+ href={artifactUrl(artifact.id)}
327
+ target="_blank"
328
+ rel="noopener noreferrer"
329
+ className="text-[10px] text-info hover:underline px-1"
330
+ onClick={(e) => e.stopPropagation()}
331
+ >
332
+ open
333
+ </a>
334
+ )}
335
+ <a
336
+ href={artifactUrl(artifact.id)}
337
+ download
338
+ className="text-[10px] text-accent hover:underline px-1"
339
+ onClick={(e) => e.stopPropagation()}
340
+ >
341
+ download
342
+ </a>
343
+ </div>
344
+ </div>
345
+
346
+ {/* Expanded inline preview */}
347
+ {expanded && viewable && (
348
+ <div data-expand className="mt-2 border-t border-border pt-2">
349
+ {loading && <div className="text-[10px] text-text-dim py-2">Loading…</div>}
350
+ {error && <div className="text-[10px] text-danger py-2">Error: {error}</div>}
351
+ {content !== null && contentType && <TextContent text={content} type={contentType} />}
352
+ </div>
353
+ )}
354
+ </div>
355
+
356
+ {/* Image lightbox */}
357
+ {lightbox && (
358
+ <ImageLightbox
359
+ src={artifactUrl(artifact.id)}
360
+ alt={artifact.description}
361
+ onClose={() => setLightbox(false)}
362
+ />
363
+ )}
364
+ </>
365
+ )
366
+ }
367
+
368
+ export function ArtifactsPanel({ artifacts }: Props) {
369
+ const ref = useRef<HTMLDivElement>(null)
370
+ const animatedRef = useRef(false)
371
+
372
+ useEffect(() => {
373
+ if (!ref.current || animatedRef.current || artifacts.length === 0) return
374
+ animatedRef.current = true
375
+ const items = ref.current.querySelectorAll("[data-artifact]")
376
+ gsap.fromTo(items, { opacity: 0, x: -6 }, { opacity: 1, x: 0, duration: 0.2, stagger: 0.03, ease: "power2.out" })
377
+ }, [artifacts.length > 0])
378
+
379
+ // Group by artifact_type
380
+ const grouped = new Map<string, Artifact[]>()
381
+ for (const a of artifacts) {
382
+ const group = grouped.get(a.artifact_type) || []
383
+ group.push(a)
384
+ grouped.set(a.artifact_type, group)
385
+ }
386
+
387
+ return (
388
+ <div ref={ref} className="space-y-3 mt-2">
389
+ <style>{`
390
+ .artifact-markdown p { margin: 0.25em 0; }
391
+ .artifact-markdown h1 { font-size: 1.25em; font-weight: 700; margin: 0.5em 0 0.25em; }
392
+ .artifact-markdown h2 { font-size: 1.1em; font-weight: 700; margin: 0.5em 0 0.25em; }
393
+ .artifact-markdown h3 { font-size: 1em; font-weight: 600; margin: 0.4em 0 0.2em; }
394
+ .artifact-markdown h4 { font-size: 0.95em; font-weight: 600; margin: 0.3em 0 0.15em; }
395
+ .artifact-markdown strong { font-weight: 700; }
396
+ .artifact-markdown em { font-style: italic; }
397
+ .artifact-markdown .artifact-code-block {
398
+ background: var(--color-bg-elevated, #1a1a2e);
399
+ padding: 0.5em;
400
+ border-radius: 4px;
401
+ overflow-x: auto;
402
+ font-size: 0.9em;
403
+ margin: 0.5em 0;
404
+ }
405
+ .artifact-markdown .artifact-inline-code {
406
+ background: var(--color-bg-elevated, #1a1a2e);
407
+ padding: 0.1em 0.3em;
408
+ border-radius: 3px;
409
+ font-size: 0.9em;
410
+ }
411
+ .artifact-markdown .artifact-li { margin-left: 1em; list-style: disc inside; }
412
+ .artifact-markdown .artifact-hr { border-color: var(--color-border, #333); margin: 0.5em 0; }
413
+ .artifact-markdown .artifact-link { color: var(--color-accent, #6af); text-decoration: underline; }
414
+ `}</style>
415
+ {Array.from(grouped.entries()).map(([type, items]) => (
416
+ <div key={type}>
417
+ <div className="text-[9px] text-text-dim uppercase tracking-wider mb-1 font-bold">
418
+ {TYPE_ICONS[type] || <File size={10} />} {type} ({items.length})
419
+ </div>
420
+ <div className="space-y-1">
421
+ {items.map((a) => (
422
+ <ArtifactRow key={a.id} artifact={a} />
423
+ ))}
424
+ </div>
425
+ </div>
426
+ ))}
427
+ </div>
428
+ )
429
+ }
@@ -0,0 +1,176 @@
1
+ import { useRef, useEffect, useState } from "react"
2
+ import gsap from "gsap"
3
+ import { ChevronDown, ChevronRight } from "lucide-react"
4
+ import type { Session } from "@/types"
5
+ import { formatDuration, formatTokens, cn } from "@/lib/utils"
6
+
7
+ interface Props {
8
+ children: Session[]
9
+ onSelectSession: (id: string) => void
10
+ }
11
+
12
+ export function ChildStreams({ children, onSelectSession }: Props) {
13
+ const ref = useRef<HTMLDivElement>(null)
14
+ const [expanded, setExpanded] = useState(true)
15
+
16
+ useEffect(() => {
17
+ if (!ref.current || !expanded) return
18
+ gsap.fromTo(
19
+ ref.current.querySelectorAll(".child-card"),
20
+ { opacity: 0, y: 10 },
21
+ { opacity: 1, y: 0, duration: 0.4, stagger: 0.06, ease: "power2.out" }
22
+ )
23
+ }, [children, expanded])
24
+
25
+ const completed = children.filter((c) => c.status === "completed").length
26
+ const failed = children.filter((c) => c.status === "failed").length
27
+ const inProgress = children.filter((c) => c.status === "in_progress").length
28
+ const totalTokens = children.reduce(
29
+ (sum, c) => sum + (c.total_input_tokens || 0) + (c.total_output_tokens || 0),
30
+ 0
31
+ )
32
+
33
+ return (
34
+ <div>
35
+ {/* Aggregated stats bar */}
36
+ <div className="flex items-center gap-4 mb-3 text-[10px]">
37
+ <button
38
+ onClick={() => setExpanded(!expanded)}
39
+ className="text-accent hover:text-text transition-colors"
40
+ >
41
+ {expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
42
+ </button>
43
+ <span className="text-text-muted">
44
+ {children.length} stream{children.length !== 1 ? "s" : ""}
45
+ </span>
46
+ {inProgress > 0 && (
47
+ <span className="text-warning font-bold">{inProgress} LIVE</span>
48
+ )}
49
+ {completed > 0 && (
50
+ <span className="text-accent">{completed} done</span>
51
+ )}
52
+ {failed > 0 && (
53
+ <span className="text-error">{failed} failed</span>
54
+ )}
55
+ <span className="text-text-dim">
56
+ {formatTokens(totalTokens)} total tokens
57
+ </span>
58
+ </div>
59
+
60
+ {/* Child cards */}
61
+ {expanded && (
62
+ <div ref={ref} data-expand className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
63
+ {children.map((child) => (
64
+ <ChildCard
65
+ key={child.id}
66
+ session={child}
67
+ onClick={() => onSelectSession(child.id)}
68
+ />
69
+ ))}
70
+ </div>
71
+ )}
72
+ </div>
73
+ )
74
+ }
75
+
76
+ function ChildCard({
77
+ session,
78
+ onClick,
79
+ }: {
80
+ session: Session
81
+ onClick: () => void
82
+ }) {
83
+ const totalTokens =
84
+ (session.total_input_tokens || 0) + (session.total_output_tokens || 0)
85
+
86
+ const statusStyles: Record<string, string> = {
87
+ completed: "border-accent/40 hover:border-accent",
88
+ failed: "border-error/40 hover:border-error",
89
+ in_progress: "border-warning/40 hover:border-warning",
90
+ interrupted: "border-text-dim/40 hover:border-text-dim",
91
+ }
92
+
93
+ const statusBadgeStyles: Record<string, string> = {
94
+ completed: "text-accent",
95
+ failed: "text-error",
96
+ in_progress: "text-warning",
97
+ interrupted: "text-text-dim",
98
+ }
99
+
100
+ return (
101
+ <div
102
+ onClick={onClick}
103
+ className={cn(
104
+ "child-card border rounded-md p-3 bg-bg cursor-pointer transition-all duration-200 hover:bg-bg-hover group",
105
+ statusStyles[session.status] || "border-border"
106
+ )}
107
+ >
108
+ {/* Header */}
109
+ <div className="flex items-center justify-between mb-2">
110
+ <span className="text-xs font-bold text-text truncate mr-2">
111
+ {session.worktree_name}
112
+ </span>
113
+ <span
114
+ className={cn(
115
+ "text-[9px] font-bold uppercase tracking-wider flex-shrink-0",
116
+ statusBadgeStyles[session.status] || "text-text-dim"
117
+ )}
118
+ >
119
+ {session.status === "in_progress" ? "LIVE" : session.status}
120
+ </span>
121
+ </div>
122
+
123
+ {/* Progress bar */}
124
+ <IterationBar
125
+ current={session.total_iterations}
126
+ max={session.max_iterations}
127
+ status={session.status}
128
+ />
129
+
130
+ {/* Stats row */}
131
+ <div className="flex gap-3 mt-2 text-[10px] text-text-dim">
132
+ <span>{formatDuration(session.duration_seconds)}</span>
133
+ <span>{formatTokens(totalTokens)} tok</span>
134
+ <span>
135
+ {session.total_iterations}/{session.max_iterations} iter
136
+ </span>
137
+ </div>
138
+
139
+ {/* Click hint */}
140
+ <div className="text-[9px] text-text-dim mt-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
141
+ click to view details &rarr;
142
+ </div>
143
+ </div>
144
+ )
145
+ }
146
+
147
+ function IterationBar({
148
+ current,
149
+ max,
150
+ status,
151
+ }: {
152
+ current: number
153
+ max: number
154
+ status: string
155
+ }) {
156
+ const pct = max > 0 ? Math.min(100, Math.round((current / max) * 100)) : 0
157
+
158
+ const barColor: Record<string, string> = {
159
+ completed: "bg-accent",
160
+ failed: "bg-error",
161
+ in_progress: "bg-warning",
162
+ interrupted: "bg-text-dim",
163
+ }
164
+
165
+ return (
166
+ <div className="w-full h-1 bg-border rounded-full overflow-hidden">
167
+ <div
168
+ className={cn(
169
+ "h-full rounded-full transition-all duration-500",
170
+ barColor[status] || "bg-text-dim"
171
+ )}
172
+ style={{ width: `${pct}%` }}
173
+ />
174
+ </div>
175
+ )
176
+ }