agentlytics 0.1.18 → 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.
package/server.js CHANGED
@@ -333,6 +333,107 @@ app.put('/api/config', (req, res) => {
333
333
  }
334
334
  });
335
335
 
336
+ app.get('/api/check-ai', async (req, res) => {
337
+ const folder = req.query.folder;
338
+ if (!folder) return res.status(400).json({ error: 'folder query param required' });
339
+ try {
340
+ const { execFile } = require('child_process');
341
+ const isWindows = process.platform === 'win32';
342
+ // On Windows, use npx.cmd with shell; on Unix, use npx directly
343
+ const cmd = isWindows ? 'npx.cmd' : 'npx';
344
+ const result = await new Promise((resolve, reject) => {
345
+ execFile(cmd, ['-y', 'check-ai', '--json', folder], {
346
+ timeout: 60000,
347
+ maxBuffer: 1024 * 1024,
348
+ shell: isWindows
349
+ }, (err, stdout) => {
350
+ try {
351
+ const json = JSON.parse(stdout);
352
+ resolve(json);
353
+ } catch (e) {
354
+ reject(new Error(err ? err.message : 'Failed to parse check-ai output'));
355
+ }
356
+ });
357
+ });
358
+ res.json(result);
359
+ } catch (err) {
360
+ res.status(500).json({ error: err.message });
361
+ }
362
+ });
363
+
364
+ // ============================================================
365
+ // Artifacts — delegates to editors/index.js getAllArtifacts
366
+ // ============================================================
367
+
368
+ app.get('/api/artifacts', (req, res) => {
369
+ try {
370
+ const { getAllArtifacts } = require('./editors');
371
+ const projects = cache.getCachedProjects({ hiddenFolders: getHiddenFolders() });
372
+ const result = [];
373
+
374
+ for (const project of projects) {
375
+ const folder = project.folder;
376
+ if (!folder) continue;
377
+
378
+ const artifacts = getAllArtifacts(folder);
379
+ if (artifacts.length === 0) continue;
380
+
381
+ // Group by editor
382
+ const byEditor = {};
383
+ for (const a of artifacts) {
384
+ if (!byEditor[a.editor]) byEditor[a.editor] = { editor: a.editor, label: a.editorLabel, files: [] };
385
+ byEditor[a.editor].files.push(a);
386
+ }
387
+
388
+ result.push({
389
+ folder,
390
+ name: project.name || path.basename(folder),
391
+ totalArtifacts: artifacts.length,
392
+ editors: Object.values(byEditor),
393
+ });
394
+ }
395
+
396
+ // Sort by total artifacts descending
397
+ result.sort((a, b) => b.totalArtifacts - a.totalArtifacts);
398
+ res.json(result);
399
+ } catch (err) {
400
+ res.status(500).json({ error: err.message });
401
+ }
402
+ });
403
+
404
+ app.get('/api/artifact-content', (req, res) => {
405
+ try {
406
+ const filePath = req.query.path;
407
+ if (!filePath) return res.status(400).json({ error: 'path query param required' });
408
+
409
+ // Security: validate file exists in known artifact results for at least one project
410
+ const { getAllArtifacts } = require('./editors');
411
+ const projects = cache.getCachedProjects({ hiddenFolders: getHiddenFolders() });
412
+ let allowed = false;
413
+ for (const project of projects) {
414
+ if (!project.folder) continue;
415
+ const artifacts = getAllArtifacts(project.folder);
416
+ if (artifacts.some(a => a.path === filePath)) { allowed = true; break; }
417
+ }
418
+ // Also check global/editor-level artifacts not tied to any project (e.g. brain files)
419
+ if (!allowed) {
420
+ try {
421
+ const artifacts = getAllArtifacts(null);
422
+ if (artifacts.some(a => a.path === filePath)) allowed = true;
423
+ } catch { /* skip */ }
424
+ }
425
+ if (!allowed) return res.status(403).json({ error: 'Not an artifact file' });
426
+
427
+ if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' });
428
+
429
+ const content = fs.readFileSync(filePath, 'utf-8');
430
+ const stat = fs.statSync(filePath);
431
+ res.json({ path: filePath, name: path.basename(filePath), content, size: stat.size, modifiedAt: stat.mtime.getTime() });
432
+ } catch (err) {
433
+ res.status(500).json({ error: err.message });
434
+ }
435
+ });
436
+
336
437
  app.get('/api/all-projects', (req, res) => {
337
438
  try {
338
439
  res.json(cache.getCachedProjects({ ...parseDateOpts(req.query), includeHidden: true }));
package/ui/src/App.jsx CHANGED
@@ -1,9 +1,10 @@
1
1
  import { useState, useEffect, useRef, useCallback } from 'react'
2
- import { Routes, Route, NavLink } from 'react-router-dom'
3
- import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, DollarSign, CreditCard, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Plug, Copy, Check, Settings as SettingsIcon } from 'lucide-react'
2
+ import { Routes, Route, NavLink, useLocation } from 'react-router-dom'
3
+ import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, DollarSign, CreditCard, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Plug, Copy, Check, Settings as SettingsIcon, Package, ChevronDown } from 'lucide-react'
4
4
  import { fetchOverview, refetchAgents, fetchMode, fetchRelayConfig, getAuthToken, setOnAuthFailure } from './lib/api'
5
5
  import { useTheme } from './lib/theme'
6
6
  import AnimatedLogo from './components/AnimatedLogo'
7
+ import AnimatedLoader from './components/AnimatedLoader'
7
8
  import LoginScreen from './components/LoginScreen'
8
9
  import Dashboard from './pages/Dashboard'
9
10
  import Sessions from './pages/Sessions'
@@ -13,11 +14,58 @@ import Projects from './pages/Projects'
13
14
  import ProjectDetail from './pages/ProjectDetail'
14
15
  import CostAnalysis from './pages/CostAnalysis'
15
16
  import SqlViewer from './pages/SqlViewer'
17
+ import Artifacts from './pages/Artifacts'
16
18
  import Settings from './pages/Settings'
17
19
  import Subscriptions from './pages/Subscriptions'
18
20
  import RelayDashboard from './pages/RelayDashboard'
19
21
  import RelayUserDetail from './pages/RelayUserDetail'
20
22
 
23
+ function NavDropdown({ icon: Icon, label, items }) {
24
+ const [open, setOpen] = useState(false)
25
+ const location = useLocation()
26
+ const isActive = items.some(i => i.to === location.pathname)
27
+ const timeout = useRef(null)
28
+
29
+ const enter = () => { clearTimeout(timeout.current); setOpen(true) }
30
+ const leave = () => { timeout.current = setTimeout(() => setOpen(false), 150) }
31
+
32
+ return (
33
+ <div className="relative" onMouseEnter={enter} onMouseLeave={leave}>
34
+ <button
35
+ className={`flex items-center gap-1.5 px-2.5 py-1 text-[12px] rounded transition ${
36
+ isActive ? 'bg-[var(--c-card)] text-[var(--c-white)]' : 'text-[var(--c-text2)] hover:text-[var(--c-white)]'
37
+ }`}
38
+ >
39
+ <Icon size={12} />
40
+ {label}
41
+ <ChevronDown size={10} style={{ opacity: 0.5 }} />
42
+ </button>
43
+ {open && (
44
+ <div
45
+ className="absolute top-full left-0 mt-1 py-1 rounded shadow-lg min-w-[160px] z-[100]"
46
+ style={{ background: 'var(--c-bg)', border: '1px solid var(--c-border)' }}
47
+ >
48
+ {items.map(({ to, icon: SubIcon, label: subLabel }) => (
49
+ <NavLink
50
+ key={to}
51
+ to={to}
52
+ onClick={() => setOpen(false)}
53
+ className={({ isActive: a }) =>
54
+ `flex items-center gap-2 px-3 py-1.5 text-[12px] transition ${
55
+ a ? 'bg-[var(--c-bg3)] text-[var(--c-white)]' : 'text-[var(--c-text2)] hover:text-[var(--c-white)] hover:bg-[var(--c-bg3)]'
56
+ }`
57
+ }
58
+ >
59
+ <SubIcon size={12} />
60
+ {subLabel}
61
+ </NavLink>
62
+ ))}
63
+ </div>
64
+ )}
65
+ </div>
66
+ )
67
+ }
68
+
21
69
  export default function App() {
22
70
  const [overview, setOverview] = useState(null)
23
71
  const [refetchState, setRefetchState] = useState(null) // null | { scanned, total }
@@ -82,16 +130,24 @@ export default function App() {
82
130
  const isRelay = mode === 'relay'
83
131
  const showLogin = isRelay && needsAuth && !authed
84
132
 
133
+ const location = useLocation()
134
+ const isFullWidth = location.pathname === '/artifacts'
135
+
85
136
  const nav = isRelay ? [
86
137
  { to: '/', icon: Users, label: 'Team' },
87
138
  ] : [
88
139
  { to: '/', icon: Activity, label: 'Dashboard' },
89
- { to: '/projects', icon: FolderOpen, label: 'Projects' },
90
140
  { to: '/sessions', icon: MessageSquare, label: 'Sessions' },
91
- { to: '/costs', icon: DollarSign, label: 'Costs' },
92
- { to: '/analysis', icon: BarChart3, label: 'Analysis' },
93
- { to: '/compare', icon: GitCompare, label: 'Compare' },
94
- { to: '/subscriptions', icon: CreditCard, label: 'Subscriptions' },
141
+ { to: '/projects', icon: FolderOpen, label: 'Projects' },
142
+ { icon: DollarSign, label: 'Costs', children: [
143
+ { to: '/costs', icon: DollarSign, label: 'Cost Analysis' },
144
+ { to: '/subscriptions', icon: CreditCard, label: 'Subscriptions' },
145
+ ]},
146
+ { icon: BarChart3, label: 'Insights', children: [
147
+ { to: '/analysis', icon: BarChart3, label: 'Deep Analysis' },
148
+ { to: '/compare', icon: GitCompare, label: 'Compare' },
149
+ ]},
150
+ { to: '/artifacts', icon: Package, label: 'Artifacts' },
95
151
  { to: '/sql', icon: Database, label: 'SQL' },
96
152
  ]
97
153
 
@@ -107,19 +163,21 @@ export default function App() {
107
163
  Agentlytics{isRelay && <span className="ml-1.5 text-[10px] font-medium px-1.5 py-0.5" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>relay</span>}
108
164
  </span>
109
165
  <nav className="flex gap-0.5 ml-2">
110
- {nav.map(({ to, icon: Icon, label }) => (
166
+ {nav.map((item) => item.children ? (
167
+ <NavDropdown key={item.label} icon={item.icon} label={item.label} items={item.children} />
168
+ ) : (
111
169
  <NavLink
112
- key={to}
113
- to={to}
114
- end={to === '/'}
170
+ key={item.to}
171
+ to={item.to}
172
+ end={item.to === '/'}
115
173
  className={({ isActive }) =>
116
174
  `flex items-center gap-1.5 px-2.5 py-1 text-[12px] rounded transition ${
117
175
  isActive ? 'bg-[var(--c-card)] text-[var(--c-white)]' : 'text-[var(--c-text2)] hover:text-[var(--c-white)]'
118
176
  }`
119
177
  }
120
178
  >
121
- <Icon size={12} />
122
- {label}
179
+ <item.icon size={12} />
180
+ {item.label}
123
181
  </NavLink>
124
182
  ))}
125
183
  </nav>
@@ -196,9 +254,9 @@ export default function App() {
196
254
  </div>
197
255
  )}
198
256
 
199
- <main className={isRelay ? 'px-0' : 'p-4 max-w-[1400px] mx-auto'}>
257
+ <main className={isRelay ? 'px-0' : isFullWidth ? 'p-0 overflow-hidden' : 'p-4 max-w-[1400px] mx-auto'}>
200
258
  {mode === null ? (
201
- <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading...</div>
259
+ <AnimatedLoader label="Loading..." />
202
260
  ) : isRelay ? (
203
261
  <Routes>
204
262
  <Route path="/" element={<RelayDashboard />} />
@@ -216,13 +274,14 @@ export default function App() {
216
274
  <Route path="/analysis" element={<DeepAnalysis overview={overview} />} />
217
275
  <Route path="/compare" element={<Compare overview={overview} />} />
218
276
  <Route path="/subscriptions" element={<Subscriptions />} />
277
+ <Route path="/artifacts" element={<Artifacts />} />
219
278
  <Route path="/sql" element={<SqlViewer />} />
220
279
  <Route path="/settings" element={<Settings />} />
221
280
  </Routes>
222
281
  )}
223
282
  </main>
224
283
 
225
- <footer className="border-t mt-8 px-4 py-3 flex items-center justify-between text-[11px]" style={{ borderColor: 'var(--c-border)', color: 'var(--c-text3)' }}>
284
+ <footer className={`border-t mt-8 px-4 py-3 flex items-center justify-between text-[11px]${isFullWidth ? ' hidden' : ''}`} style={{ borderColor: 'var(--c-border)', color: 'var(--c-text3)' }}>
226
285
  <div className="flex items-center gap-3">
227
286
  <a href="https://github.com/f/agentlytics" target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-[var(--c-text)] transition">
228
287
  <Github size={11} />
@@ -0,0 +1,254 @@
1
+ import { useState, useEffect, useRef } from 'react'
2
+ import { ShieldCheck, Loader2, CheckCircle2, AlertTriangle, RefreshCw, Info, ChevronRight, ChevronDown, Brush, FileText, FlaskConical, Bot, Lock, Puzzle, Plug, Package } from 'lucide-react'
3
+ import { fetchCheckAi } from '../lib/api'
4
+ import SectionTitle from './SectionTitle'
5
+
6
+ const GRADE_COLORS = {
7
+ 'A+': '#22c55e', A: '#22c55e',
8
+ 'B+': '#4ade80', B: '#4ade80',
9
+ 'C+': '#facc15', C: '#facc15',
10
+ 'D+': '#f97316', D: '#f97316',
11
+ F: '#ef4444',
12
+ }
13
+
14
+ const SECTION_ICONS = {
15
+ 'Repo Hygiene': Brush,
16
+ 'Grounding Docs': FileText,
17
+ 'Testing': FlaskConical,
18
+ 'Agent Configs': Bot,
19
+ 'AI Context': Lock,
20
+ 'Prompts & Skills': Puzzle,
21
+ 'MCP': Plug,
22
+ 'AI Dependencies': Package,
23
+ 'AI Deps': Package,
24
+ }
25
+
26
+ function Tip({ missing }) {
27
+ const [open, setOpen] = useState(false)
28
+ const ref = useRef(null)
29
+
30
+ useEffect(() => {
31
+ if (!open) return
32
+ const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false) }
33
+ document.addEventListener('mousedown', handler)
34
+ return () => document.removeEventListener('mousedown', handler)
35
+ }, [open])
36
+
37
+ const weighted = missing.filter(f => f.weight > 0)
38
+ const items = weighted.length > 0 ? weighted : missing.slice(0, 5)
39
+ if (items.length === 0) return null
40
+
41
+ return (
42
+ <span ref={ref} className="relative flex-shrink-0">
43
+ <button
44
+ onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
45
+ className="flex items-center justify-center w-4 h-4 rounded-full transition hover:opacity-80"
46
+ style={{ color: 'var(--c-text3)', background: 'var(--c-bg3)' }}
47
+ >
48
+ <Info size={10} />
49
+ </button>
50
+ {open && (
51
+ <div
52
+ className="absolute right-0 top-6 z-50 w-64 p-2.5 rounded shadow-lg"
53
+ style={{ background: 'var(--c-bg)', border: '1px solid var(--c-border)', boxShadow: '0 4px 24px rgba(0,0,0,0.3)' }}
54
+ >
55
+ <div className="text-[11px] font-medium mb-1.5" style={{ color: 'var(--c-white)' }}>How to improve</div>
56
+ <div className="space-y-1">
57
+ {items.map(f => (
58
+ <div key={f.id} className="text-[11px]" style={{ color: 'var(--c-text2)' }}>
59
+ <span style={{ color: 'var(--c-text)' }}>+ {f.label}</span>
60
+ {f.weight > 0 && <span style={{ color: 'var(--c-text3)' }}> ({f.weight}pt)</span>}
61
+ </div>
62
+ ))}
63
+ </div>
64
+ </div>
65
+ )}
66
+ </span>
67
+ )
68
+ }
69
+
70
+ export default function AiAuditCard({ folder }) {
71
+ console.log('AiAuditCard rendering, folder:', folder)
72
+ const [audit, setAudit] = useState(null)
73
+ const [loading, setLoading] = useState(false)
74
+ const [error, setError] = useState(null)
75
+ const [expanded, setExpanded] = useState(new Set())
76
+
77
+ const runAudit = async () => {
78
+ if (!folder) return
79
+ setLoading(true)
80
+ setError(null)
81
+ try {
82
+ const result = await fetchCheckAi(folder)
83
+ if (result.error) throw new Error(result.error)
84
+ setAudit(result)
85
+ } catch (e) {
86
+ setError(e.message)
87
+ }
88
+ setLoading(false)
89
+ }
90
+
91
+ useEffect(() => {
92
+ console.log('AiAuditCard useEffect triggered, folder:', folder)
93
+ runAudit()
94
+ }, [folder])
95
+
96
+ if (loading) {
97
+ return (
98
+ <div className="card p-4">
99
+ <div className="flex items-center gap-2">
100
+ <Loader2 size={14} className="animate-spin" style={{ color: 'var(--c-accent)' }} />
101
+ <span className="text-[12px]" style={{ color: 'var(--c-text2)' }}>Running AI readiness audit...</span>
102
+ </div>
103
+ </div>
104
+ )
105
+ }
106
+
107
+ if (error) {
108
+ return (
109
+ <div className="card p-4">
110
+ <div className="flex items-center justify-between">
111
+ <div className="flex items-center gap-2">
112
+ <AlertTriangle size={14} style={{ color: '#ef4444' }} />
113
+ <span className="text-[12px]" style={{ color: 'var(--c-text2)' }}>Audit failed: {error}</span>
114
+ </div>
115
+ <button
116
+ onClick={runAudit}
117
+ className="flex items-center gap-1.5 px-2.5 py-1 text-[11px] rounded transition hover:opacity-80"
118
+ style={{ color: 'var(--c-text2)', border: '1px solid var(--c-border)' }}
119
+ >
120
+ <RefreshCw size={10} />
121
+ Retry
122
+ </button>
123
+ </div>
124
+ </div>
125
+ )
126
+ }
127
+
128
+ if (!audit) return null
129
+
130
+ const gradeColor = GRADE_COLORS[audit.grade] || 'var(--c-text2)'
131
+ const sections = audit.sections || {}
132
+ const findings = audit.findings || []
133
+
134
+ const findingsBySection = {}
135
+ for (const f of findings) {
136
+ const sec = f.section || 'Other'
137
+ if (!findingsBySection[sec]) findingsBySection[sec] = []
138
+ findingsBySection[sec].push(f)
139
+ }
140
+
141
+ return (
142
+ <div className="card p-4 space-y-3">
143
+ {/* Header row */}
144
+ <div className="flex items-center gap-3">
145
+ <ShieldCheck size={16} style={{ color: gradeColor }} />
146
+ <div>
147
+ <SectionTitle>ai readiness audit</SectionTitle>
148
+ <div className="text-[10px] -mt-1.5" style={{ color: 'var(--c-text3)' }}>powered by <code style={{ fontFamily: 'JetBrains Mono, monospace' }}>npx check-ai</code></div>
149
+ </div>
150
+ <div className="flex items-center gap-2 ml-auto">
151
+ <span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>
152
+ {audit.checks?.passed || 0}/{audit.checks?.total || 0} checks
153
+ &middot; {audit.points?.earned || 0}/{audit.points?.max || 0} pts
154
+ </span>
155
+ <button
156
+ onClick={runAudit}
157
+ className="flex items-center gap-1 px-2 py-1 text-[11px] rounded transition hover:opacity-80"
158
+ style={{ color: 'var(--c-text2)', border: '1px solid var(--c-border)' }}
159
+ >
160
+ <RefreshCw size={10} />
161
+ </button>
162
+ </div>
163
+ </div>
164
+
165
+ {/* Score row */}
166
+ <div className="flex items-center gap-3">
167
+ <div
168
+ className="w-11 h-11 rounded-lg flex items-center justify-center text-base font-black flex-shrink-0"
169
+ style={{ background: `${gradeColor}15`, color: gradeColor, border: `1.5px solid ${gradeColor}30` }}
170
+ >
171
+ {audit.grade}
172
+ </div>
173
+ <div className="flex-1 min-w-0">
174
+ <div className="flex items-baseline gap-1.5">
175
+ <span className="text-base font-bold" style={{ color: 'var(--c-white)' }}>{audit.score}</span>
176
+ <span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>/10</span>
177
+ <span className="text-[11px] ml-1" style={{ color: 'var(--c-text2)' }}>{audit.label}</span>
178
+ </div>
179
+ <div className="w-full h-2 rounded-full overflow-hidden mt-1" style={{ background: 'var(--c-code-bg)' }}>
180
+ <div className="h-full rounded-full" style={{ width: `${((audit.score || 0) / 10 * 100).toFixed(1)}%`, background: gradeColor }} />
181
+ </div>
182
+ </div>
183
+ </div>
184
+
185
+ {/* Section rows */}
186
+ <div>
187
+ {Object.entries(sections).map(([name, sec]) => {
188
+ const Icon = SECTION_ICONS[name] || FileText
189
+ const sectionFindings = findingsBySection[name] || []
190
+ const passed = sectionFindings.filter(f => f.found)
191
+ const missing = sectionFindings.filter(f => !f.found)
192
+ const pct = sec.pct || 0
193
+ const barColor = pct >= 70 ? '#22c55e' : pct >= 40 ? '#facc15' : pct > 0 ? '#f97316' : 'var(--c-text3)'
194
+
195
+ // Build description from found findings
196
+ const details = []
197
+ for (const f of passed) {
198
+ if (f.detail) details.push(f.detail)
199
+ else if (f.matchedPath) details.push(f.matchedPath)
200
+ else if (f.matches && f.matches.length > 0) details.push(f.matches.join(', '))
201
+ else details.push(f.label)
202
+ }
203
+ const desc = details.length > 0
204
+ ? details.slice(0, 3).join(' · ') + (details.length > 3 ? ' …' : '')
205
+ : 'none detected'
206
+
207
+ const isOpen = expanded.has(name)
208
+ const toggle = () => setExpanded(prev => {
209
+ const next = new Set(prev)
210
+ if (next.has(name)) next.delete(name)
211
+ else next.add(name)
212
+ return next
213
+ })
214
+
215
+ return (
216
+ <div key={name} style={{ borderBottom: '1px solid var(--c-border)' }}>
217
+ {/* Main row — clickable */}
218
+ <div className="flex items-center gap-2.5 py-2 cursor-pointer transition hover:bg-[var(--c-bg3)]" onClick={toggle}>
219
+ {isOpen
220
+ ? <ChevronDown size={12} style={{ color: 'var(--c-text3)', flexShrink: 0 }} />
221
+ : <ChevronRight size={12} style={{ color: 'var(--c-text3)', flexShrink: 0 }} />
222
+ }
223
+ <Icon size={14} style={{ color: 'var(--c-text3)', flexShrink: 0 }} />
224
+ <span className="text-[12px] font-medium w-32 flex-shrink-0" style={{ color: 'var(--c-white)' }}>{name}</span>
225
+ <span className="text-[11px] flex-1 truncate" style={{ color: passed.length > 0 ? 'var(--c-text2)' : 'var(--c-text3)' }}>
226
+ {desc}
227
+ </span>
228
+ <span className="text-[11px] flex-shrink-0" style={{ color: 'var(--c-text3)' }}>{passed.length}/{sectionFindings.length}</span>
229
+ <div className="w-20 h-2 rounded-full overflow-hidden flex-shrink-0" style={{ background: 'var(--c-code-bg)' }}>
230
+ <div className="h-full rounded-full" style={{ width: `${pct}%`, background: barColor }} />
231
+ </div>
232
+ <span className="text-[11px] w-9 text-right font-bold flex-shrink-0" style={{ color: barColor }}>{pct}%</span>
233
+ {missing.length > 0 && <Tip missing={missing} />}
234
+ </div>
235
+ {/* Expanded: found findings */}
236
+ {isOpen && passed.length > 0 && (
237
+ <div className="flex flex-wrap gap-x-4 gap-y-0.5 pb-2 pl-12">
238
+ {passed.map(f => (
239
+ <span key={f.id} className="inline-flex items-center gap-1.5 text-[11px] py-0.5">
240
+ <CheckCircle2 size={10} style={{ color: '#22c55e', flexShrink: 0 }} />
241
+ <span style={{ color: 'var(--c-text)' }}>{f.label}</span>
242
+ {f.matchedPath && <span style={{ color: 'var(--c-text3)' }}>{f.matchedPath}</span>}
243
+ {f.detail && <span style={{ color: 'var(--c-text2)' }}>— {f.detail}</span>}
244
+ </span>
245
+ ))}
246
+ </div>
247
+ )}
248
+ </div>
249
+ )
250
+ })}
251
+ </div>
252
+ </div>
253
+ )
254
+ }
@@ -0,0 +1,14 @@
1
+ import AnimatedLogo from './AnimatedLogo'
2
+
3
+ export default function AnimatedLoader({ label = 'Loading...' }) {
4
+ return (
5
+ <div className="flex flex-col items-center justify-center py-16 gap-3">
6
+ <div style={{ opacity: 0.7 }}>
7
+ <AnimatedLogo size={32} />
8
+ </div>
9
+ {label && (
10
+ <span className="text-[12px]" style={{ color: 'var(--c-text3)' }}>{label}</span>
11
+ )}
12
+ </div>
13
+ )
14
+ }
package/ui/src/lib/api.js CHANGED
@@ -203,11 +203,30 @@ export async function fetchToolCalls(name, opts = {}) {
203
203
  return res.json();
204
204
  }
205
205
 
206
+ export async function fetchCheckAi(folder) {
207
+ const q = new URLSearchParams({ folder });
208
+ const res = await fetch(`${BASE}/api/check-ai?${q}`);
209
+ return res.json();
210
+ }
211
+
206
212
  export async function fetchUsage() {
207
213
  const res = await fetch(`${BASE}/api/usage`);
208
214
  return res.json();
209
215
  }
210
216
 
217
+ // ── Artifacts API ──
218
+
219
+ export async function fetchArtifacts() {
220
+ const res = await fetch(`${BASE}/api/artifacts`);
221
+ return res.json();
222
+ }
223
+
224
+ export async function fetchArtifactContent(filePath) {
225
+ const q = new URLSearchParams({ path: filePath });
226
+ const res = await fetch(`${BASE}/api/artifact-content?${q}`);
227
+ return res.json();
228
+ }
229
+
211
230
  // ── Relay API ──
212
231
 
213
232
  export async function fetchMode() {