agentlytics 0.1.19 → 0.2.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/cache.js +3 -2
- package/editors/antigravity.js +532 -31
- package/editors/base.js +87 -0
- package/editors/claude.js +11 -1
- package/editors/codex.js +11 -0
- package/editors/copilot.js +11 -1
- package/editors/cursor.js +11 -1
- package/editors/gemini.js +11 -1
- package/editors/goose.js +30 -8
- package/editors/index.js +40 -1
- package/editors/kiro.js +11 -1
- package/editors/opencode.js +4 -22
- package/editors/vscode.js +11 -1
- package/editors/windsurf.js +21 -10
- package/editors/zed.js +23 -3
- package/index.js +40 -38
- package/package.json +1 -1
- package/server.js +74 -1
- package/ui/src/App.jsx +75 -16
- package/ui/src/components/AiAuditCard.jsx +4 -5
- package/ui/src/components/AnimatedLoader.jsx +14 -0
- package/ui/src/lib/api.js +13 -0
- package/ui/src/pages/Artifacts.jsx +600 -0
- package/ui/src/pages/CostAnalysis.jsx +2 -1
- package/ui/src/pages/Dashboard.jsx +2 -1
- package/ui/src/pages/ProjectDetail.jsx +3 -1
- package/ui/src/pages/Projects.jsx +2 -1
- package/ui/src/pages/RelayDashboard.jsx +2 -1
- package/ui/src/pages/Settings.jsx +2 -1
- package/ui/src/pages/Subscriptions.jsx +2 -1
|
@@ -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 <
|
|
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 <
|
|
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 <
|
|
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 */}
|