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.
- package/Dockerfile +22 -0
- package/bin/tanuki.mjs +251 -0
- package/frontend/eslint.config.js +23 -0
- package/frontend/index.html +13 -0
- package/frontend/package.json +39 -0
- package/frontend/src/App.tsx +232 -0
- package/frontend/src/assets/hero.png +0 -0
- package/frontend/src/assets/react.svg +1 -0
- package/frontend/src/assets/vite.svg +1 -0
- package/frontend/src/components/ArtifactsPanel.tsx +429 -0
- package/frontend/src/components/ChildStreams.tsx +176 -0
- package/frontend/src/components/CoordinatorPage.tsx +317 -0
- package/frontend/src/components/Header.tsx +108 -0
- package/frontend/src/components/InsightsPanel.tsx +142 -0
- package/frontend/src/components/IterationsTable.tsx +98 -0
- package/frontend/src/components/KnowledgePage.tsx +308 -0
- package/frontend/src/components/LoginPage.tsx +55 -0
- package/frontend/src/components/PlanProgress.tsx +163 -0
- package/frontend/src/components/QualityReport.tsx +276 -0
- package/frontend/src/components/ScreenshotUpload.tsx +117 -0
- package/frontend/src/components/ScreenshotsGrid.tsx +266 -0
- package/frontend/src/components/SessionDetail.tsx +265 -0
- package/frontend/src/components/SessionList.tsx +234 -0
- package/frontend/src/components/SettingsPage.tsx +213 -0
- package/frontend/src/components/StreamComms.tsx +228 -0
- package/frontend/src/components/TanukiLogo.tsx +16 -0
- package/frontend/src/components/Timeline.tsx +416 -0
- package/frontend/src/components/WalkthroughPage.tsx +458 -0
- package/frontend/src/hooks/useApi.ts +81 -0
- package/frontend/src/hooks/useAuth.ts +54 -0
- package/frontend/src/hooks/useKnowledge.ts +33 -0
- package/frontend/src/hooks/useWebSocket.ts +95 -0
- package/frontend/src/index.css +66 -0
- package/frontend/src/lib/api.ts +15 -0
- package/frontend/src/lib/utils.ts +58 -0
- package/frontend/src/main.tsx +10 -0
- package/frontend/src/types.ts +181 -0
- package/frontend/tsconfig.app.json +32 -0
- package/frontend/tsconfig.json +7 -0
- package/frontend/vite.config.ts +25 -0
- package/install.sh +87 -0
- package/package.json +63 -0
- package/src/api-keys.ts +97 -0
- package/src/auth.ts +165 -0
- package/src/coordinator.ts +136 -0
- package/src/dashboard-server.ts +5 -0
- package/src/dashboard.ts +826 -0
- package/src/db.ts +1009 -0
- package/src/index.ts +20 -0
- package/src/middleware.ts +76 -0
- package/src/tools.ts +864 -0
- package/src/types-shim.d.ts +18 -0
- package/src/types.ts +171 -0
- 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, "&")
|
|
63
|
+
.replace(/</g, "<")
|
|
64
|
+
.replace(/>/g, ">")
|
|
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 →
|
|
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
|
+
}
|