agentlytics 0.1.19 → 0.1.20

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.
@@ -0,0 +1,600 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { FolderOpen, FileText, ChevronRight, ChevronDown, Search, Package, Clock, Hash, X, Code, Eye, DollarSign, Type } from 'lucide-react'
3
+ import ReactMarkdown from 'react-markdown'
4
+ import remarkGfm from 'remark-gfm'
5
+ import { fetchArtifacts, fetchArtifactContent } from '../lib/api'
6
+ import { editorColor, editorLabel } from '../lib/constants'
7
+ import EditorIcon from '../components/EditorIcon'
8
+ import AnimatedLoader from '../components/AnimatedLoader'
9
+
10
+ const MONO = 'JetBrains Mono, monospace'
11
+
12
+ const EDITOR_ICONS = {
13
+ 'claude-code': '🟠',
14
+ 'cursor': '🟡',
15
+ 'windsurf': '🔵',
16
+ 'kiro': '🟠',
17
+ 'copilot-cli': '🟣',
18
+ 'codex': '🟢',
19
+ 'gemini-cli': '🔵',
20
+ 'goose': '⚫',
21
+ '_general': '📄',
22
+ }
23
+
24
+ function formatSize(bytes) {
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 formatDate(ts) {
31
+ if (!ts) return ''
32
+ return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })
33
+ }
34
+
35
+ function estimateTokens(text) {
36
+ if (!text) return 0
37
+ // ~4 chars per token is a reasonable approximation
38
+ return Math.round(text.length / 4)
39
+ }
40
+
41
+ function formatTokens(n) {
42
+ if (n < 1000) return String(n)
43
+ if (n < 1000000) return (n / 1000).toFixed(1) + 'k'
44
+ return (n / 1000000).toFixed(2) + 'M'
45
+ }
46
+
47
+ // Rough est. based on average input token pricing (~$3/1M tokens)
48
+ function estimateCost(tokens) {
49
+ const cost = (tokens / 1000000) * 3
50
+ if (cost < 0.001) return '<$0.001'
51
+ if (cost < 0.01) return '$' + cost.toFixed(4)
52
+ return '$' + cost.toFixed(3)
53
+ }
54
+
55
+ function getFileType(fileName) {
56
+ if (!fileName) return 'text'
57
+ const lower = fileName.toLowerCase()
58
+ if (lower.endsWith('.json')) return 'json'
59
+ if (lower.endsWith('.md') || lower.endsWith('.mdc')) return 'markdown'
60
+ if (lower.endsWith('.yaml') || lower.endsWith('.yml')) return 'yaml'
61
+ return 'text'
62
+ }
63
+
64
+ function JsonViewer({ content }) {
65
+ let parsed
66
+ try {
67
+ parsed = JSON.parse(content)
68
+ } catch {
69
+ return <pre className="text-[12px] whitespace-pre-wrap break-words leading-relaxed" style={{ color: '#ef4444', fontFamily: MONO }}>Invalid JSON</pre>
70
+ }
71
+
72
+ const formatted = JSON.stringify(parsed, null, 2)
73
+
74
+ return (
75
+ <pre className="text-[12px] whitespace-pre-wrap break-words leading-relaxed" style={{ fontFamily: MONO }}>
76
+ {formatted.split('\n').map((line, i) => {
77
+ const parts = []
78
+ let remaining = line
79
+
80
+ // Highlight JSON keys
81
+ const keyMatch = remaining.match(/^(\s*)"([^"]+)"(:)/)
82
+ if (keyMatch) {
83
+ parts.push(<span key={`i${i}`} style={{ color: 'var(--c-text3)' }}>{keyMatch[1]}</span>)
84
+ parts.push(<span key={`k${i}`} style={{ color: '#818cf8' }}>"{keyMatch[2]}"</span>)
85
+ parts.push(<span key={`c${i}`} style={{ color: 'var(--c-text3)' }}>:</span>)
86
+ remaining = remaining.slice(keyMatch[0].length)
87
+ }
88
+
89
+ // Highlight values
90
+ const strMatch = remaining.match(/^(\s*)"([^"]*)"(.*)/)
91
+ if (strMatch) {
92
+ parts.push(<span key={`sp${i}`}>{strMatch[1]}</span>)
93
+ parts.push(<span key={`v${i}`} style={{ color: '#22c55e' }}>"{strMatch[2]}"</span>)
94
+ parts.push(<span key={`r${i}`} style={{ color: 'var(--c-text3)' }}>{strMatch[3]}</span>)
95
+ } else {
96
+ // Numbers, booleans, null
97
+ const valMatch = remaining.match(/^(\s*)(true|false|null|-?\d+\.?\d*)(.*)?/)
98
+ if (valMatch) {
99
+ parts.push(<span key={`sp${i}`}>{valMatch[1]}</span>)
100
+ parts.push(<span key={`v${i}`} style={{ color: valMatch[2] === 'null' ? '#ef4444' : valMatch[2] === 'true' || valMatch[2] === 'false' ? '#f59e0b' : '#22d3ee' }}>{valMatch[2]}</span>)
101
+ parts.push(<span key={`r${i}`} style={{ color: 'var(--c-text3)' }}>{valMatch[3] || ''}</span>)
102
+ } else {
103
+ parts.push(<span key={`x${i}`} style={{ color: 'var(--c-text3)' }}>{remaining}</span>)
104
+ }
105
+ }
106
+
107
+ return <span key={i}>{parts}{'\n'}</span>
108
+ })}
109
+ </pre>
110
+ )
111
+ }
112
+
113
+ function parseFrontmatter(raw) {
114
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
115
+ if (!match) return { frontmatter: null, body: raw }
116
+ const entries = []
117
+ const lines = match[1].split('\n')
118
+ for (let i = 0; i < lines.length; i++) {
119
+ const kv = lines[i].match(/^(\w[\w\-]*):\s*(.*)/)
120
+ if (kv) {
121
+ const key = kv[1]
122
+ const inlineVal = kv[2].replace(/^["']|["']$/g, '').trim()
123
+ if (inlineVal) {
124
+ entries.push([key, inlineVal])
125
+ } else {
126
+ // Collect list items (could be simple strings, checkboxes, or YAML objects)
127
+ const items = []
128
+ while (i + 1 < lines.length && /^\s+-\s/.test(lines[i + 1])) {
129
+ i++
130
+ const item = lines[i].replace(/^\s+-\s*/, '')
131
+ const checkMatch = item.match(/^\[([ xX])\]\s*(.*)/)
132
+ if (checkMatch) {
133
+ items.push({ type: 'checkbox', checked: checkMatch[1] !== ' ', text: checkMatch[2] })
134
+ } else {
135
+ // Could be start of a YAML object (e.g. "id: scaffold-core")
136
+ const objKv = item.match(/^(\w[\w\-]*):\s*(.*)/)
137
+ if (objKv) {
138
+ const obj = { [objKv[1]]: objKv[2].replace(/^["']|["']$/g, '') }
139
+ // Collect remaining properties of this object
140
+ while (i + 1 < lines.length && /^\s{4,}\w/.test(lines[i + 1]) && !/^\s+-/.test(lines[i + 1])) {
141
+ i++
142
+ const propMatch = lines[i].trim().match(/^(\w[\w\-]*):\s*(.*)/)
143
+ if (propMatch) obj[propMatch[1]] = propMatch[2].replace(/^["']|["']$/g, '')
144
+ }
145
+ items.push({ type: 'object', data: obj })
146
+ } else {
147
+ items.push({ type: 'text', text: item })
148
+ }
149
+ }
150
+ }
151
+ entries.push([key, items.length ? items : ''])
152
+ }
153
+ }
154
+ }
155
+ return { frontmatter: entries.length ? entries : null, body: match[2] }
156
+ }
157
+
158
+ const STATUS_STYLES = {
159
+ completed: { bg: 'rgba(34,197,94,0.12)', color: '#22c55e', icon: '✓' },
160
+ in_progress: { bg: 'rgba(99,102,241,0.12)', color: '#818cf8', icon: '◐' },
161
+ pending: { bg: 'rgba(250,204,21,0.12)', color: '#facc15', icon: '○' },
162
+ blocked: { bg: 'rgba(239,68,68,0.12)', color: '#ef4444', icon: '✕' },
163
+ }
164
+
165
+ function FrontmatterValue({ val }) {
166
+ if (typeof val === 'string') return <span style={{ color: 'var(--c-text2)' }}>{val}</span>
167
+ if (!Array.isArray(val)) return null
168
+
169
+ // Check if items are YAML objects (e.g. todos with id/content/status)
170
+ const hasObjects = val.some(item => item.type === 'object')
171
+ if (hasObjects) {
172
+ return (
173
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4, width: '100%' }}>
174
+ {val.map((item, i) => {
175
+ if (item.type !== 'object') return <span key={i} style={{ color: 'var(--c-text2)' }}>{item.text || ''}</span>
176
+ const d = item.data
177
+ const status = (d.status || '').toLowerCase().replace(/\s+/g, '_')
178
+ const st = STATUS_STYLES[status] || STATUS_STYLES.pending
179
+ const isComplete = status === 'completed'
180
+ return (
181
+ <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 0' }}>
182
+ <span style={{
183
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
184
+ width: 16, height: 16, borderRadius: 3, flexShrink: 0,
185
+ background: st.bg, color: st.color, fontSize: 10, fontWeight: 700,
186
+ }}>{st.icon}</span>
187
+ <span style={{ color: 'var(--c-text2)', opacity: isComplete ? 0.55 : 1, textDecoration: isComplete ? 'line-through' : 'none', flex: 1 }}>
188
+ {d.content || d.title || d.name || d.id || JSON.stringify(d)}
189
+ </span>
190
+ {d.status && (
191
+ <span style={{ fontSize: 10, padding: '1px 6px', borderRadius: 3, background: st.bg, color: st.color, flexShrink: 0 }}>
192
+ {d.status}
193
+ </span>
194
+ )}
195
+ </div>
196
+ )
197
+ })}
198
+ </div>
199
+ )
200
+ }
201
+
202
+ return (
203
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
204
+ {val.map((item, i) => (
205
+ <label key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--c-text2)', cursor: 'default' }}>
206
+ {item.type === 'checkbox' ? (
207
+ <span style={{
208
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
209
+ width: 14, height: 14, borderRadius: 3, flexShrink: 0,
210
+ border: item.checked ? 'none' : '1.5px solid var(--c-text3)',
211
+ background: item.checked ? '#6366f1' : 'transparent',
212
+ color: '#fff', fontSize: 10, lineHeight: 1,
213
+ }}>
214
+ {item.checked ? '✓' : ''}
215
+ </span>
216
+ ) : (
217
+ <span style={{ color: 'var(--c-text3)' }}>•</span>
218
+ )}
219
+ <span style={{ textDecoration: item.checked ? 'line-through' : 'none', opacity: item.checked ? 0.5 : 1 }}>{item.text}</span>
220
+ </label>
221
+ ))}
222
+ </div>
223
+ )
224
+ }
225
+
226
+ function MarkdownViewer({ content }) {
227
+ const { frontmatter, body } = parseFrontmatter(content)
228
+ return (
229
+ <div className="artifact-markdown" style={{ color: 'var(--c-text)', fontSize: 14, lineHeight: 1.7, maxWidth: 720, margin: '0 auto' }}>
230
+ {frontmatter && (
231
+ <div style={{ background: 'var(--c-bg)', border: '1px solid var(--c-border)', borderRadius: 6, padding: '10px 14px', marginBottom: 16, fontSize: 12, fontFamily: MONO }}>
232
+ {frontmatter.map(([key, val], i) => (
233
+ <div key={i} style={{ display: 'flex', gap: 8, padding: '3px 0', alignItems: Array.isArray(val) ? 'flex-start' : 'center' }}>
234
+ <span style={{ color: '#818cf8', minWidth: 100, flexShrink: 0, paddingTop: Array.isArray(val) ? 1 : 0 }}>{key}</span>
235
+ <FrontmatterValue val={val} />
236
+ </div>
237
+ ))}
238
+ </div>
239
+ )}
240
+ <ReactMarkdown
241
+ remarkPlugins={[remarkGfm]}
242
+ components={{
243
+ h1: ({ children }) => <h1 style={{ fontSize: 22, fontWeight: 700, margin: '0 0 12px 0', paddingBottom: 8, borderBottom: '1px solid var(--c-border)', color: 'var(--c-white)' }}>{children}</h1>,
244
+ h2: ({ children }) => <h2 style={{ fontSize: 18, fontWeight: 600, margin: '20px 0 8px 0', paddingBottom: 6, borderBottom: '1px solid var(--c-border)', color: 'var(--c-white)' }}>{children}</h2>,
245
+ h3: ({ children }) => <h3 style={{ fontSize: 15, fontWeight: 600, margin: '16px 0 6px 0', color: 'var(--c-white)' }}>{children}</h3>,
246
+ h4: ({ children }) => <h4 style={{ fontSize: 14, fontWeight: 600, margin: '12px 0 4px 0', color: 'var(--c-white)' }}>{children}</h4>,
247
+ p: ({ children }) => <p style={{ margin: '0 0 10px 0' }}>{children}</p>,
248
+ ul: ({ children }) => <ul style={{ margin: '0 0 10px 0', paddingLeft: 24, listStyleType: 'disc' }}>{children}</ul>,
249
+ ol: ({ children }) => <ol style={{ margin: '0 0 10px 0', paddingLeft: 24, listStyleType: 'decimal' }}>{children}</ol>,
250
+ li: ({ children, className }) => {
251
+ const isTask = className === 'task-list-item'
252
+ return <li style={{ margin: '2px 0', listStyleType: isTask ? 'none' : undefined, marginLeft: isTask ? -20 : 0 }}>{children}</li>
253
+ },
254
+ input: ({ type, checked }) => {
255
+ if (type === 'checkbox') {
256
+ return (
257
+ <span style={{
258
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
259
+ width: 15, height: 15, borderRadius: 3, marginRight: 6, verticalAlign: -2, flexShrink: 0,
260
+ border: checked ? 'none' : '1.5px solid var(--c-text3)',
261
+ background: checked ? '#6366f1' : 'transparent',
262
+ color: '#fff', fontSize: 10,
263
+ }}>{checked ? '✓' : ''}</span>
264
+ )
265
+ }
266
+ return null
267
+ },
268
+ pre: ({ children }) => (
269
+ <pre style={{ background: 'var(--c-bg)', border: '1px solid var(--c-border)', borderRadius: 6, padding: 12, margin: '8px 0 12px 0', overflow: 'auto', fontFamily: MONO, fontSize: 12, color: 'var(--c-text)', lineHeight: 1.6 }}>
270
+ {children}
271
+ </pre>
272
+ ),
273
+ code: ({ node, children }) => {
274
+ const isBlock = node?.position && node.position.start.line !== node.position.end.line
275
+ if (isBlock) return <code>{children}</code>
276
+ return <code style={{ background: 'var(--c-bg3)', padding: '1px 5px', borderRadius: 4, fontFamily: MONO, fontSize: 12, color: '#818cf8' }}>{children}</code>
277
+ },
278
+ blockquote: ({ children }) => (
279
+ <blockquote style={{ borderLeft: '3px solid #6366f1', paddingLeft: 12, margin: '8px 0', color: 'var(--c-text2)', fontStyle: 'italic' }}>{children}</blockquote>
280
+ ),
281
+ table: ({ children }) => (
282
+ <div style={{ overflow: 'auto', margin: '8px 0 12px 0' }}>
283
+ <table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 13 }}>{children}</table>
284
+ </div>
285
+ ),
286
+ thead: ({ children }) => <thead style={{ borderBottom: '2px solid var(--c-border)' }}>{children}</thead>,
287
+ th: ({ children }) => <th style={{ textAlign: 'left', padding: '6px 12px', fontWeight: 600, color: 'var(--c-white)' }}>{children}</th>,
288
+ td: ({ children }) => <td style={{ padding: '5px 12px', borderBottom: '1px solid var(--c-border)' }}>{children}</td>,
289
+ a: ({ href, children }) => <a href={href} target="_blank" rel="noopener noreferrer" style={{ color: '#818cf8', textDecoration: 'underline', textUnderlineOffset: 2 }}>{children}</a>,
290
+ hr: () => <hr style={{ border: 'none', borderTop: '1px solid var(--c-border)', margin: '16px 0' }} />,
291
+ strong: ({ children }) => <strong style={{ color: 'var(--c-white)', fontWeight: 600 }}>{children}</strong>,
292
+ img: ({ src, alt }) => <img src={src} alt={alt} style={{ maxWidth: '100%', borderRadius: 6, margin: '8px 0' }} />,
293
+ }}
294
+ >
295
+ {body}
296
+ </ReactMarkdown>
297
+ </div>
298
+ )
299
+ }
300
+
301
+ function ArtifactContent({ content, fileName, viewRaw }) {
302
+ if (viewRaw) {
303
+ return (
304
+ <pre
305
+ className="text-[12px] whitespace-pre-wrap break-words leading-relaxed"
306
+ style={{ color: 'var(--c-text)', fontFamily: MONO }}
307
+ >{content}</pre>
308
+ )
309
+ }
310
+
311
+ const fileType = getFileType(fileName)
312
+
313
+ if (fileType === 'json') return <JsonViewer content={content} />
314
+ if (fileType === 'markdown') return <MarkdownViewer content={content} />
315
+
316
+ // yaml and other text files — show as raw
317
+ return (
318
+ <pre
319
+ className="text-[12px] whitespace-pre-wrap break-words leading-relaxed"
320
+ style={{ color: 'var(--c-text)', fontFamily: MONO }}
321
+ >{content}</pre>
322
+ )
323
+ }
324
+
325
+ export default function Artifacts() {
326
+ const [data, setData] = useState(null)
327
+ const [search, setSearch] = useState('')
328
+ const [expandedProjects, setExpandedProjects] = useState(new Set())
329
+ const [expandedEditors, setExpandedEditors] = useState(new Set())
330
+ const [selectedFile, setSelectedFile] = useState(null)
331
+ const [fileContent, setFileContent] = useState(null)
332
+ const [loadingContent, setLoadingContent] = useState(false)
333
+ const [viewRaw, setViewRaw] = useState(false)
334
+
335
+ useEffect(() => {
336
+ fetchArtifacts().then(d => {
337
+ setData(d)
338
+ // Auto-expand first project
339
+ if (d && d.length > 0) {
340
+ setExpandedProjects(new Set([d[0].folder]))
341
+ if (d[0].editors.length > 0) {
342
+ setExpandedEditors(new Set([d[0].folder + '::' + d[0].editors[0].editor]))
343
+ }
344
+ }
345
+ })
346
+ }, [])
347
+
348
+ const handleFileClick = async (file) => {
349
+ setSelectedFile(file)
350
+ setViewRaw(false)
351
+ setLoadingContent(true)
352
+ try {
353
+ const content = await fetchArtifactContent(file.path)
354
+ setFileContent(content)
355
+ } catch {
356
+ setFileContent({ error: 'Failed to load file content' })
357
+ }
358
+ setLoadingContent(false)
359
+ }
360
+
361
+ const toggleProject = (folder) => {
362
+ setExpandedProjects(prev => {
363
+ const next = new Set(prev)
364
+ if (next.has(folder)) next.delete(folder)
365
+ else next.add(folder)
366
+ return next
367
+ })
368
+ }
369
+
370
+ const toggleEditor = (key) => {
371
+ setExpandedEditors(prev => {
372
+ const next = new Set(prev)
373
+ if (next.has(key)) next.delete(key)
374
+ else next.add(key)
375
+ return next
376
+ })
377
+ }
378
+
379
+ if (!data) return <AnimatedLoader label="Scanning project artifacts..." />
380
+
381
+ const filtered = data.filter(p => {
382
+ if (!search) return true
383
+ const q = search.toLowerCase()
384
+ if (p.name.toLowerCase().includes(q) || p.folder.toLowerCase().includes(q)) return true
385
+ return p.editors.some(e =>
386
+ e.label.toLowerCase().includes(q) ||
387
+ e.files.some(f => f.name.toLowerCase().includes(q))
388
+ )
389
+ })
390
+
391
+ const totalArtifacts = data.reduce((s, p) => s + p.totalArtifacts, 0)
392
+ const totalProjects = data.length
393
+ const allEditors = new Set()
394
+ for (const p of data) for (const e of p.editors) allEditors.add(e.editor)
395
+
396
+ return (
397
+ <div className="h-full">
398
+ {/* Header stats */}
399
+ <div className="flex items-center gap-4 mb-0 px-4 py-3" style={{ borderBottom: '1px solid var(--c-border)' }}>
400
+ <div className="flex items-center gap-4">
401
+ <div className="flex items-center gap-2 px-3 py-1.5 rounded" style={{ background: 'var(--c-card)', border: '1px solid var(--c-border)' }}>
402
+ <Package size={13} style={{ color: '#6366f1' }} />
403
+ <span className="text-[12px] font-medium" style={{ color: 'var(--c-white)' }}>{totalArtifacts}</span>
404
+ <span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>artifacts</span>
405
+ </div>
406
+ <div className="flex items-center gap-2 px-3 py-1.5 rounded" style={{ background: 'var(--c-card)', border: '1px solid var(--c-border)' }}>
407
+ <FolderOpen size={13} style={{ color: '#6366f1' }} />
408
+ <span className="text-[12px] font-medium" style={{ color: 'var(--c-white)' }}>{totalProjects}</span>
409
+ <span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>projects</span>
410
+ </div>
411
+ <div className="flex items-center gap-2 px-3 py-1.5 rounded" style={{ background: 'var(--c-card)', border: '1px solid var(--c-border)' }}>
412
+ <Hash size={13} style={{ color: '#6366f1' }} />
413
+ <span className="text-[12px] font-medium" style={{ color: 'var(--c-white)' }}>{allEditors.size}</span>
414
+ <span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>editors</span>
415
+ </div>
416
+ </div>
417
+ <div className="ml-auto relative">
418
+ <Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
419
+ <input
420
+ type="text"
421
+ placeholder="Search artifacts..."
422
+ value={search}
423
+ onChange={e => setSearch(e.target.value)}
424
+ className="pl-7 pr-3 py-1.5 text-[12px] rounded w-[240px]"
425
+ style={{ background: 'var(--c-card)', border: '1px solid var(--c-border)', color: 'var(--c-text)', outline: 'none' }}
426
+ />
427
+ </div>
428
+ </div>
429
+
430
+ {filtered.length === 0 ? (
431
+ <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text3)' }}>
432
+ {search ? 'No artifacts match your search' : 'No AI artifacts found in any project folders'}
433
+ </div>
434
+ ) : null}
435
+
436
+ <div className="flex" style={{ height: 'calc(100vh - 130px)' }}>
437
+ {/* Sidebar: project > editor tree */}
438
+ <div className="w-[340px] shrink-0 flex flex-col" style={{ background: 'var(--c-card)', borderRight: '1px solid var(--c-border)' }}>
439
+ <div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--c-text3)', borderBottom: '1px solid var(--c-border)' }}>
440
+ Project Tree
441
+ </div>
442
+ <div className="overflow-y-auto flex-1">
443
+ {filtered.map(project => {
444
+ const isExpanded = expandedProjects.has(project.folder)
445
+ return (
446
+ <div key={project.folder}>
447
+ {/* Project row */}
448
+ <button
449
+ onClick={() => toggleProject(project.folder)}
450
+ className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--c-bg3)] transition"
451
+ style={{ borderBottom: '1px solid var(--c-border)' }}
452
+ >
453
+ {isExpanded ? <ChevronDown size={12} style={{ color: 'var(--c-text3)' }} /> : <ChevronRight size={12} style={{ color: 'var(--c-text3)' }} />}
454
+ <FolderOpen size={13} style={{ color: '#6366f1' }} />
455
+ <div className="min-w-0 flex-1">
456
+ <div className="text-[12px] font-medium truncate" style={{ color: 'var(--c-white)' }}>{project.name}</div>
457
+ <div className="text-[10px] truncate" style={{ color: 'var(--c-text3)', fontFamily: MONO }}>{project.folder}</div>
458
+ </div>
459
+ <span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: 'rgba(99,102,241,0.12)', color: '#818cf8' }}>
460
+ {project.totalArtifacts}
461
+ </span>
462
+ </button>
463
+
464
+ {/* Editor groups */}
465
+ {isExpanded && project.editors.map(editorGroup => {
466
+ const editorKey = project.folder + '::' + editorGroup.editor
467
+ const isEditorExpanded = expandedEditors.has(editorKey)
468
+ return (
469
+ <div key={editorKey}>
470
+ <button
471
+ onClick={() => toggleEditor(editorKey)}
472
+ className="w-full flex items-center gap-2 pl-7 pr-3 py-1.5 text-left hover:bg-[var(--c-bg3)] transition"
473
+ >
474
+ {isEditorExpanded ? <ChevronDown size={10} style={{ color: 'var(--c-text3)' }} /> : <ChevronRight size={10} style={{ color: 'var(--c-text3)' }} />}
475
+ {editorGroup.editor !== '_general' ? (
476
+ <EditorIcon source={editorGroup.editor} size={12} />
477
+ ) : (
478
+ <FileText size={12} style={{ color: 'var(--c-text2)' }} />
479
+ )}
480
+ <span className="text-[11px] font-medium" style={{ color: editorGroup.editor !== '_general' ? editorColor(editorGroup.editor) : 'var(--c-text2)' }}>
481
+ {editorGroup.editor !== '_general' ? editorLabel(editorGroup.editor) : editorGroup.label}
482
+ </span>
483
+ <span className="text-[10px] ml-auto" style={{ color: 'var(--c-text3)' }}>
484
+ {editorGroup.files.length}
485
+ </span>
486
+ </button>
487
+
488
+ {/* File list */}
489
+ {isEditorExpanded && editorGroup.files.map(file => (
490
+ <button
491
+ key={file.path}
492
+ onClick={() => handleFileClick(file)}
493
+ className="w-full flex items-center gap-2 pl-12 pr-3 py-1.5 text-left hover:bg-[var(--c-bg3)] transition"
494
+ style={{
495
+ background: selectedFile?.path === file.path ? 'var(--c-bg3)' : 'transparent',
496
+ borderLeft: selectedFile?.path === file.path ? '2px solid #6366f1' : '2px solid transparent',
497
+ }}
498
+ >
499
+ <FileText size={11} style={{ color: 'var(--c-text3)' }} />
500
+ <div className="min-w-0 flex-1">
501
+ <div className="text-[11px] truncate" style={{ color: 'var(--c-text)', fontFamily: MONO }}>{file.relativePath}</div>
502
+ <div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>
503
+ {formatSize(file.size)} · {file.lines} lines
504
+ </div>
505
+ </div>
506
+ </button>
507
+ ))}
508
+ </div>
509
+ )
510
+ })}
511
+ </div>
512
+ )
513
+ })}
514
+ </div>
515
+ </div>
516
+
517
+ {/* Content panel — scrolls internally */}
518
+ <div className="flex-1 flex flex-col min-w-0 overflow-hidden" style={{ background: 'var(--c-card)' }}>
519
+ {!selectedFile ? (
520
+ <div className="flex items-center justify-center flex-1 text-[13px]" style={{ color: 'var(--c-text3)' }}>
521
+ <div className="text-center">
522
+ <Package size={32} className="mx-auto mb-3 opacity-30" />
523
+ <div>Select an artifact to view its contents</div>
524
+ <div className="text-[11px] mt-1 opacity-60">Click any file in the sidebar tree</div>
525
+ </div>
526
+ </div>
527
+ ) : (
528
+ <div className="flex flex-col h-full">
529
+ {/* File header */}
530
+ <div className="flex items-center gap-3 px-4 py-2.5" style={{ borderBottom: '1px solid var(--c-border)' }}>
531
+ <FileText size={14} style={{ color: '#6366f1' }} />
532
+ <div className="flex-1 min-w-0">
533
+ <div className="text-[12px] font-medium" style={{ color: 'var(--c-white)', fontFamily: MONO }}>{selectedFile.relativePath}</div>
534
+ <div className="flex items-center gap-3 text-[10px]" style={{ color: 'var(--c-text3)' }}>
535
+ <span>{formatSize(selectedFile.size)}</span>
536
+ <span>{selectedFile.lines} lines</span>
537
+ <span className="flex items-center gap-1">
538
+ <Clock size={9} />
539
+ {formatDate(selectedFile.modifiedAt)}
540
+ </span>
541
+ </div>
542
+ </div>
543
+ {fileContent?.content && (() => {
544
+ const tokens = estimateTokens(fileContent.content)
545
+ return (
546
+ <div className="flex items-center gap-2">
547
+ <span className="flex items-center gap-1 text-[10px] px-2 py-0.5 rounded" style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.15)', color: '#818cf8' }}>
548
+ <Type size={9} />
549
+ {formatTokens(tokens)} tokens
550
+ </span>
551
+ <span className="flex items-center gap-1 text-[10px] px-2 py-0.5 rounded" style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.15)', color: '#22c55e' }}>
552
+ <DollarSign size={9} />
553
+ {estimateCost(tokens)}
554
+ </span>
555
+ </div>
556
+ )
557
+ })()}
558
+ <span className="flex items-center gap-1.5 text-[10px] px-2 py-0.5 rounded" style={{ background: `${editorColor(selectedFile.editor)}15`, color: editorColor(selectedFile.editor) }}>
559
+ {selectedFile.editor !== '_general' && <EditorIcon source={selectedFile.editor} size={11} />}
560
+ {selectedFile.editorLabel}
561
+ </span>
562
+ <button
563
+ onClick={() => setViewRaw(v => !v)}
564
+ className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium transition"
565
+ style={{
566
+ background: viewRaw ? 'rgba(99,102,241,0.15)' : 'var(--c-bg3)',
567
+ color: viewRaw ? '#818cf8' : 'var(--c-text2)',
568
+ border: '1px solid ' + (viewRaw ? 'rgba(99,102,241,0.3)' : 'var(--c-border)'),
569
+ }}
570
+ title={viewRaw ? 'Switch to rendered view' : 'Switch to raw view'}
571
+ >
572
+ {viewRaw ? <Eye size={12} /> : <Code size={12} />}
573
+ {viewRaw ? 'Rendered' : 'Raw'}
574
+ </button>
575
+ <button
576
+ onClick={() => { setSelectedFile(null); setFileContent(null) }}
577
+ className="p-1 rounded hover:bg-[var(--c-bg3)] transition"
578
+ style={{ color: 'var(--c-text3)' }}
579
+ >
580
+ <X size={14} />
581
+ </button>
582
+ </div>
583
+
584
+ {/* File content — internal scroll */}
585
+ <div className="flex-1 overflow-y-auto p-4">
586
+ {loadingContent ? (
587
+ <div className="text-[12px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>Loading...</div>
588
+ ) : fileContent?.error ? (
589
+ <div className="text-[12px] py-8 text-center" style={{ color: '#ef4444' }}>{fileContent.error}</div>
590
+ ) : fileContent?.content ? (
591
+ <ArtifactContent content={fileContent.content} fileName={selectedFile.name} viewRaw={viewRaw} />
592
+ ) : null}
593
+ </div>
594
+ </div>
595
+ )}
596
+ </div>
597
+ </div>
598
+ </div>
599
+ )
600
+ }
@@ -8,6 +8,7 @@ import { editorColor, editorLabel, formatNumber, formatCost, formatDate, dateRan
8
8
  import { useTheme } from '../lib/theme'
9
9
  import KpiCard from '../components/KpiCard'
10
10
  import EditorIcon from '../components/EditorIcon'
11
+ import AnimatedLoader from '../components/AnimatedLoader'
11
12
  import SectionTitle from '../components/SectionTitle'
12
13
  import DateRangePicker from '../components/DateRangePicker'
13
14
  import ChatSidebar from '../components/ChatSidebar'
@@ -41,7 +42,7 @@ export default function CostAnalysis({ overview }) {
41
42
  }, [editor, apiDateRange])
42
43
 
43
44
  if (!data) {
44
- return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading cost data...</div>
45
+ return <AnimatedLoader label="Loading cost data..." />
45
46
  }
46
47
 
47
48
  const { totalCost, byModel, byEditor, byProject, monthly, topSessions, summary, unknownModels } = data
@@ -10,6 +10,7 @@ import { editorColor, editorLabel, formatNumber, formatCost, dateRangeToApiParam
10
10
  import EditorIcon from '../components/EditorIcon'
11
11
  import { fetchDailyActivity, fetchOverview as fetchOverviewApi, fetchDashboardStats, fetchChats, fetchCosts } from '../lib/api'
12
12
  import ChatSidebar from '../components/ChatSidebar'
13
+ import AnimatedLoader from '../components/AnimatedLoader'
13
14
  import ShareModal from '../components/ShareModal'
14
15
  import { useTheme } from '../lib/theme'
15
16
  import SectionTitle from '../components/SectionTitle'
@@ -84,7 +85,7 @@ export default function Dashboard({ overview }) {
84
85
  })
85
86
  }, [selectedEditor, dateRange])
86
87
 
87
- if (!overview) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading...</div>
88
+ if (!overview) return <AnimatedLoader label="Loading dashboard..." />
88
89
 
89
90
  const d = filteredData || overview
90
91
  const allEditors = overview.editors.sort((a, b) => b.count - a.count)
@@ -10,6 +10,7 @@ import KpiCard from '../components/KpiCard'
10
10
  import EditorIcon from '../components/EditorIcon'
11
11
  import SectionTitle from '../components/SectionTitle'
12
12
  import ChatSidebar from '../components/ChatSidebar'
13
+ import AnimatedLoader from '../components/AnimatedLoader'
13
14
  import AiAuditCard from '../components/AiAuditCard'
14
15
 
15
16
  ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement)
@@ -77,7 +78,7 @@ export default function ProjectDetail() {
77
78
  }
78
79
 
79
80
  if (!folder) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text3)' }}>no project specified</div>
80
- if (loading) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading project...</div>
81
+ if (loading) return <AnimatedLoader label="Loading project..." />
81
82
  if (!project) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text3)' }}>project not found</div>
82
83
 
83
84
  const editorEntries = Object.entries(project.editors).sort((a, b) => b[1] - a[1])
@@ -235,6 +236,7 @@ export default function ProjectDetail() {
235
236
  </div>
236
237
 
237
238
  {/* AI Readiness Audit */}
239
+ {console.log('Rendering AiAuditCard, folder:', folder)}
238
240
  <AiAuditCard folder={folder} />
239
241
 
240
242
  {/* Sessions */}