agentlytics 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/cache.js +420 -10
  3. package/editors/cursor.js +28 -6
  4. package/editors/vscode.js +6 -0
  5. package/index.js +78 -11
  6. package/package.json +2 -1
  7. package/server.js +27 -0
  8. package/ui/package-lock.json +60 -375
  9. package/ui/package.json +1 -1
  10. package/ui/src/App.jsx +22 -17
  11. package/ui/src/components/ActivityHeatmap.jsx +3 -3
  12. package/ui/src/components/AnimatedLogo.jsx +96 -0
  13. package/ui/src/components/ChatSidebar.jsx +7 -7
  14. package/ui/src/components/DateRangePicker.jsx +5 -5
  15. package/ui/src/components/EditorBreakdown.jsx +2 -2
  16. package/ui/src/components/EditorDot.jsx +1 -1
  17. package/ui/src/components/KpiCard.jsx +2 -2
  18. package/ui/src/components/LiveFeed.jsx +8 -8
  19. package/ui/src/components/LoginScreen.jsx +8 -6
  20. package/ui/src/components/MessageRenderer.jsx +5 -5
  21. package/ui/src/components/ModelBreakdown.jsx +3 -3
  22. package/ui/src/components/SectionTitle.jsx +1 -1
  23. package/ui/src/index.css +1 -1
  24. package/ui/src/lib/api.js +20 -0
  25. package/ui/src/lib/constants.js +8 -0
  26. package/ui/src/pages/ChatDetail.jsx +5 -2
  27. package/ui/src/pages/Compare.jsx +18 -18
  28. package/ui/src/pages/CostAnalysis.jsx +356 -0
  29. package/ui/src/pages/Dashboard.jsx +39 -21
  30. package/ui/src/pages/DeepAnalysis.jsx +38 -31
  31. package/ui/src/pages/ProjectDetail.jsx +23 -15
  32. package/ui/src/pages/Projects.jsx +14 -8
  33. package/ui/src/pages/RelayDashboard.jsx +29 -29
  34. package/ui/src/pages/RelaySessionDetail.jsx +1 -1
  35. package/ui/src/pages/RelayUserDetail.jsx +18 -18
  36. package/ui/src/pages/Sessions.jsx +24 -20
  37. package/ui/src/pages/SqlViewer.jsx +14 -14
package/ui/package.json CHANGED
@@ -35,4 +35,4 @@
35
35
  "overrides": {
36
36
  "vite": "^8.0.0-beta.13"
37
37
  }
38
- }
38
+ }
package/ui/src/App.jsx CHANGED
@@ -1,8 +1,9 @@
1
1
  import { useState, useEffect, useRef, useCallback } from 'react'
2
2
  import { Routes, Route, NavLink } from 'react-router-dom'
3
- import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Radio, Plug, Copy, Check } from 'lucide-react'
3
+ import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, DollarSign, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Radio, Plug, Copy, Check } from 'lucide-react'
4
4
  import { fetchOverview, refetchAgents, fetchMode, fetchRelayConfig, getAuthToken, setOnAuthFailure } from './lib/api'
5
5
  import { useTheme } from './lib/theme'
6
+ import AnimatedLogo from './components/AnimatedLogo'
6
7
  import LoginScreen from './components/LoginScreen'
7
8
  import Dashboard from './pages/Dashboard'
8
9
  import Sessions from './pages/Sessions'
@@ -11,6 +12,7 @@ import Compare from './pages/Compare'
11
12
  import ChatDetail from './pages/ChatDetail'
12
13
  import Projects from './pages/Projects'
13
14
  import ProjectDetail from './pages/ProjectDetail'
15
+ import CostAnalysis from './pages/CostAnalysis'
14
16
  import SqlViewer from './pages/SqlViewer'
15
17
  import RelayDashboard from './pages/RelayDashboard'
16
18
  import RelayUserDetail from './pages/RelayUserDetail'
@@ -85,6 +87,7 @@ export default function App() {
85
87
  { to: '/', icon: Activity, label: 'Dashboard' },
86
88
  { to: '/projects', icon: FolderOpen, label: 'Projects' },
87
89
  { to: '/sessions', icon: MessageSquare, label: 'Sessions' },
90
+ { to: '/costs', icon: DollarSign, label: 'Costs' },
88
91
  { to: '/analysis', icon: BarChart3, label: 'Analysis' },
89
92
  { to: '/compare', icon: GitCompare, label: 'Compare' },
90
93
  { to: '/sql', icon: Database, label: 'SQL' },
@@ -97,8 +100,9 @@ export default function App() {
97
100
  return (
98
101
  <div className="min-h-screen">
99
102
  <header className="border-b px-4 py-1.5 flex items-center gap-3 sticky top-0 z-50 backdrop-blur-xl" style={{ borderColor: 'var(--c-border)', background: 'var(--c-header)' }}>
100
- <span className="text-xs font-bold tracking-tight" style={{ color: 'var(--c-white)' }}>
101
- npx agentlytics{isRelay && <span className="ml-1.5 text-[9px] font-medium px-1.5 py-0.5" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>relay</span>}
103
+ <span className="flex items-center gap-1.5 text-xs font-bold tracking-tight" style={{ color: 'var(--c-white)' }}>
104
+ <AnimatedLogo size={18} />
105
+ 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>}
102
106
  </span>
103
107
  <nav className="flex gap-0.5 ml-2">
104
108
  {nav.map(({ to, icon: Icon, label }) => (
@@ -107,7 +111,7 @@ export default function App() {
107
111
  to={to}
108
112
  end={to === '/'}
109
113
  className={({ isActive }) =>
110
- `flex items-center gap-1.5 px-2.5 py-1 text-[11px] rounded transition ${
114
+ `flex items-center gap-1.5 px-2.5 py-1 text-[12px] rounded transition ${
111
115
  isActive ? 'bg-[var(--c-card)] text-[var(--c-white)]' : 'text-[var(--c-text2)] hover:text-[var(--c-white)]'
112
116
  }`
113
117
  }
@@ -122,7 +126,7 @@ export default function App() {
122
126
  <>
123
127
  <button
124
128
  onClick={() => setLive(!live)}
125
- className="flex items-center gap-1.5 px-2 py-0.5 text-[10px] transition"
129
+ className="flex items-center gap-1.5 px-2 py-0.5 text-[11px] transition"
126
130
  style={{
127
131
  color: live ? '#22c55e' : 'var(--c-text3)',
128
132
  border: live ? '1px solid rgba(34,197,94,0.3)' : '1px solid var(--c-border)',
@@ -139,7 +143,7 @@ export default function App() {
139
143
  <button
140
144
  onClick={handleRefetch}
141
145
  disabled={!!refetchState}
142
- className="flex items-center gap-1 px-2 py-0.5 text-[10px] rounded transition hover:bg-[var(--c-card)]"
146
+ className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded transition hover:bg-[var(--c-card)]"
143
147
  style={{ color: 'var(--c-text2)', border: '1px solid var(--c-border)' }}
144
148
  title="Clear cache and rescan all editors"
145
149
  >
@@ -148,7 +152,7 @@ export default function App() {
148
152
  ? `Refetching (${refetchState.scanned}/${refetchState.total})...`
149
153
  : 'Refetch'}
150
154
  </button>
151
- <span className="text-[10px]" style={{ color: 'var(--c-text2)' }}>
155
+ <span className="text-[11px]" style={{ color: 'var(--c-text2)' }}>
152
156
  {overview ? `${overview.totalChats} sessions` : '...'}
153
157
  </span>
154
158
  </>
@@ -156,7 +160,7 @@ export default function App() {
156
160
  {isRelay && (
157
161
  <button
158
162
  onClick={() => { setMcpOpen(true); setMcpCopied(false) }}
159
- className="flex items-center gap-1.5 px-2 py-0.5 text-[10px] transition hover:bg-[var(--c-card)]"
163
+ className="flex items-center gap-1.5 px-2 py-0.5 text-[11px] transition hover:bg-[var(--c-card)]"
160
164
  style={{ color: '#818cf8', border: '1px solid var(--c-border)' }}
161
165
  title="MCP Connection"
162
166
  >
@@ -176,7 +180,7 @@ export default function App() {
176
180
  </header>
177
181
 
178
182
  {refetchState && (
179
- <div className="flex items-center gap-2 px-4 py-1.5 text-[11px]" style={{ background: 'rgba(234,179,8,0.08)', borderBottom: '1px solid rgba(234,179,8,0.15)', color: '#ca8a04' }}>
183
+ <div className="flex items-center gap-2 px-4 py-1.5 text-[12px]" style={{ background: 'rgba(234,179,8,0.08)', borderBottom: '1px solid rgba(234,179,8,0.15)', color: '#ca8a04' }}>
180
184
  <AlertTriangle size={12} />
181
185
  <span>Windsurf, Windsurf Next, and Antigravity require their app to be running during refetch — otherwise their sessions won't be detected.</span>
182
186
  </div>
@@ -198,6 +202,7 @@ export default function App() {
198
202
  <Route path="/projects/detail" element={<ProjectDetail />} />
199
203
  <Route path="/sessions" element={<Sessions overview={overview} />} />
200
204
  {/* ChatDetail is now a sidebar in Sessions */}
205
+ <Route path="/costs" element={<CostAnalysis overview={overview} />} />
201
206
  <Route path="/analysis" element={<DeepAnalysis overview={overview} />} />
202
207
  <Route path="/compare" element={<Compare overview={overview} />} />
203
208
  <Route path="/sql" element={<SqlViewer />} />
@@ -205,7 +210,7 @@ export default function App() {
205
210
  )}
206
211
  </main>
207
212
 
208
- <footer className="border-t mt-8 px-4 py-3 flex items-center justify-between text-[10px]" style={{ borderColor: 'var(--c-border)', color: 'var(--c-text3)' }}>
213
+ <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)' }}>
209
214
  <div className="flex items-center gap-3">
210
215
  <a href="https://github.com/f/agentlytics" target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-[var(--c-text)] transition">
211
216
  <Github size={11} />
@@ -237,9 +242,9 @@ export default function App() {
237
242
  <button onClick={() => setMcpOpen(false)} className="text-[18px] leading-none px-1 hover:opacity-70 transition" style={{ color: 'var(--c-text3)' }}>&times;</button>
238
243
  </div>
239
244
 
240
- <div className="text-[11px] font-medium mb-1.5" style={{ color: 'var(--c-white)' }}>MCP Config</div>
245
+ <div className="text-[12px] font-medium mb-1.5" style={{ color: 'var(--c-white)' }}>MCP Config</div>
241
246
  <div className="flex items-center justify-between mb-1">
242
- <div className="text-[9px]" style={{ color: 'var(--c-text3)' }}>Add to your AI client's MCP settings</div>
247
+ <div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>Add to your AI client's MCP settings</div>
243
248
  <button
244
249
  onClick={() => {
245
250
  const json = JSON.stringify({ "mcpServers": { "agentlytics": { "url": `${window.location.origin}/mcp` } } }, null, 2)
@@ -247,21 +252,21 @@ export default function App() {
247
252
  setMcpCopied(true)
248
253
  setTimeout(() => setMcpCopied(false), 2000)
249
254
  }}
250
- className="flex items-center gap-1 px-1.5 py-0.5 text-[9px] transition hover:bg-[var(--c-bg3)]"
255
+ className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] transition hover:bg-[var(--c-bg3)]"
251
256
  style={{ border: '1px solid var(--c-border)', color: mcpCopied ? '#22c55e' : 'var(--c-text2)' }}
252
257
  >
253
258
  {mcpCopied ? <><Check size={9} /> Copied</> : <><Copy size={9} /> Copy</>}
254
259
  </button>
255
260
  </div>
256
261
  <pre
257
- className="text-[10px] px-3 py-2 overflow-x-auto mb-4"
262
+ className="text-[11px] px-3 py-2 overflow-x-auto mb-4"
258
263
  style={{ background: 'var(--c-bg3)', border: '1px solid var(--c-border)', color: 'var(--c-text)', fontFamily: 'JetBrains Mono, monospace', lineHeight: 1.6 }}
259
264
  >{`{\n "mcpServers": {\n "agentlytics": {\n "url": "${window.location.origin}/mcp"\n }\n }\n}`}</pre>
260
265
 
261
- <div className="text-[11px] font-medium mb-1.5" style={{ color: 'var(--c-white)' }}>Join Command</div>
262
- <div className="text-[9px] mb-1" style={{ color: 'var(--c-text3)' }}>Share with your team to start syncing sessions</div>
266
+ <div className="text-[12px] font-medium mb-1.5" style={{ color: 'var(--c-white)' }}>Join Command</div>
267
+ <div className="text-[10px] mb-1" style={{ color: 'var(--c-text3)' }}>Share with your team to start syncing sessions</div>
263
268
  <pre
264
- className="text-[10px] px-3 py-2 overflow-x-auto"
269
+ className="text-[11px] px-3 py-2 overflow-x-auto"
265
270
  style={{ background: 'var(--c-bg3)', border: '1px solid var(--c-border)', color: 'var(--c-text)', fontFamily: 'JetBrains Mono, monospace', lineHeight: 1.6 }}
266
271
  >{`cd /path/to/your-project\nRELAY_PASSWORD=${relayPassword || '<pass>'} npx agentlytics --join ${window.location.host}`}</pre>
267
272
  </div>
@@ -144,7 +144,7 @@ export default function ActivityHeatmap({ dailyData }) {
144
144
  </svg>
145
145
  </div>
146
146
 
147
- <div className="flex items-center gap-1.5 mt-1 text-[9px]" style={{ color: 'var(--c-text3)' }}>
147
+ <div className="flex items-center gap-1.5 mt-1 text-[10px]" style={{ color: 'var(--c-text3)' }}>
148
148
  <span>less</span>
149
149
  {COLORS.map((color, i) => (
150
150
  <span key={i} className="inline-block w-[9px] h-[9px] rounded-sm" style={{ background: color }} />
@@ -158,13 +158,13 @@ export default function ActivityHeatmap({ dailyData }) {
158
158
  <div className="flex items-center justify-between mb-2">
159
159
  <div>
160
160
  <span className="text-xs font-medium" style={{ color: 'var(--c-white)' }}>{selectedDay.key}</span>
161
- <span className="text-[10px] ml-2" style={{ color: 'var(--c-text2)' }}>
161
+ <span className="text-[11px] ml-2" style={{ color: 'var(--c-text2)' }}>
162
162
  {selectedDay.count} session{selectedDay.count !== 1 ? 's' : ''}
163
163
  {' · '}
164
164
  {Object.entries(selectedDay.data.editors || {}).map(([e, c]) => `${editorLabel(e)}: ${c}`).join(', ')}
165
165
  </span>
166
166
  </div>
167
- <button onClick={() => setSelectedDay(null)} className="text-[10px] transition" style={{ color: 'var(--c-text2)' }}>close</button>
167
+ <button onClick={() => setSelectedDay(null)} className="text-[11px] transition" style={{ color: 'var(--c-text2)' }}>close</button>
168
168
  </div>
169
169
  {hourlyChart && (
170
170
  <div style={{ height: 140 }}>
@@ -0,0 +1,96 @@
1
+ export default function AnimatedLogo({ size = 22 }) {
2
+ // Carousel: a horizontal strip of bots slides left continuously.
3
+ // The viewport shows one bot at a time. The next bot pushes the current one out.
4
+ // 5 slots (4 bots + repeat of first) for seamless loop.
5
+ const step = 28 // each bot cell width
6
+ // Keyframes: hold on each bot, then slide to the next.
7
+ // 4 stops: 0→1→2→3→0 (repeat first = slot 4)
8
+ // Each stop: 5% slide + 20% hold = 25% per bot
9
+ return (
10
+ <svg
11
+ viewBox="0 0 24 24"
12
+ width={size}
13
+ height={size}
14
+ fill="none"
15
+ style={{ overflow: 'hidden' }}
16
+ >
17
+ <style>{`
18
+ @keyframes carousel {
19
+ 0% { transform: translateX(0); }
20
+ 5% { transform: translateX(0); }
21
+ 25% { transform: translateX(-${step}px); }
22
+ 30% { transform: translateX(-${step}px); }
23
+ 50% { transform: translateX(-${step * 2}px); }
24
+ 55% { transform: translateX(-${step * 2}px); }
25
+ 75% { transform: translateX(-${step * 3}px); }
26
+ 80% { transform: translateX(-${step * 3}px); }
27
+ 100% { transform: translateX(-${step * 4}px); }
28
+ }
29
+ .carousel-strip { animation: carousel 4s cubic-bezier(0.45, 0, 0.55, 1) infinite; }
30
+ `}</style>
31
+
32
+ <g className="carousel-strip">
33
+ {/* Slot 0: Bot 1 — indigo */}
34
+ <g transform="translate(0, 0)" stroke="#818cf8" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
35
+ <path d="M12 8V4H8"/>
36
+ <rect width="16" height="12" x="4" y="8" rx="2"/>
37
+ <path d="M2 14h2"/>
38
+ <path d="M20 14h2"/>
39
+ <path d="M15 13v2"/>
40
+ <path d="M9 13v2"/>
41
+ </g>
42
+
43
+ {/* Slot 1: Bot 2 — pink */}
44
+ <g transform={`translate(${step}, 0)`} stroke="#f472b6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
45
+ <path d="M12 6V2H8"/>
46
+ <path d="M15 11v2"/>
47
+ <path d="M2 12h2"/>
48
+ <path d="M20 12h2"/>
49
+ <path d="M20 16a2 2 0 0 1-2 2H8.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 4 20.286V8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2z"/>
50
+ <path d="M9 11v2"/>
51
+ </g>
52
+
53
+ {/* Slot 2: Bot 3 — emerald */}
54
+ <g transform={`translate(${step * 2}, 0)`} stroke="#34d399" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
55
+ <path d="M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2l0-4"/>
56
+ <path d="M12 2v2"/>
57
+ <path d="M9 12v9"/>
58
+ <path d="M15 12v9"/>
59
+ <path d="M5 16l4-2"/>
60
+ <path d="M15 14l4 2"/>
61
+ <path d="M9 18h6"/>
62
+ <path d="M10 8v.01"/>
63
+ <path d="M14 8v.01"/>
64
+ </g>
65
+
66
+ {/* Slot 3: Bot 4 — amber */}
67
+ <g transform={`translate(${step * 3}, 0)`} stroke="#fbbf24" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
68
+ <path d="M12 20v2"/>
69
+ <path d="M12 2v2"/>
70
+ <path d="M17 20v2"/>
71
+ <path d="M17 2v2"/>
72
+ <path d="M2 12h2"/>
73
+ <path d="M2 17h2"/>
74
+ <path d="M2 7h2"/>
75
+ <path d="M20 12h2"/>
76
+ <path d="M20 17h2"/>
77
+ <path d="M20 7h2"/>
78
+ <path d="M7 20v2"/>
79
+ <path d="M7 2v2"/>
80
+ <rect x="4" y="4" width="16" height="16" rx="2"/>
81
+ <rect x="8" y="8" width="8" height="8" rx="1"/>
82
+ </g>
83
+
84
+ {/* Slot 4: Repeat Bot 1 for seamless loop — indigo */}
85
+ <g transform={`translate(${step * 4}, 0)`} stroke="#818cf8" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
86
+ <path d="M12 8V4H8"/>
87
+ <rect width="16" height="12" x="4" y="8" rx="2"/>
88
+ <path d="M2 14h2"/>
89
+ <path d="M20 14h2"/>
90
+ <path d="M15 13v2"/>
91
+ <path d="M9 13v2"/>
92
+ </g>
93
+ </g>
94
+ </svg>
95
+ )
96
+ }
@@ -77,7 +77,7 @@ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, use
77
77
  <div className="text-sm font-medium truncate" style={{ color: 'var(--c-white)' }}>
78
78
  {chat.name || '(untitled)'}
79
79
  </div>
80
- <div className="flex items-center gap-2 text-[10px]" style={{ color: 'var(--c-text2)' }}>
80
+ <div className="flex items-center gap-2 text-[11px]" style={{ color: 'var(--c-text2)' }}>
81
81
  <span className="inline-flex items-center gap-1">
82
82
  <span className="w-1.5 h-1.5 rounded-full" style={{ background: editorColor(chat.source) }} />
83
83
  {editorLabel(chat.source)}
@@ -91,7 +91,7 @@ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, use
91
91
  <a
92
92
  href={`${BASE}/api/chats/${chat.id}/markdown`}
93
93
  download
94
- className="flex items-center gap-1 px-2 py-1 text-[10px] transition shrink-0"
94
+ className="flex items-center gap-1 px-2 py-1 text-[11px] transition shrink-0"
95
95
  style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
96
96
  >
97
97
  <Download size={11} /> .md
@@ -102,7 +102,7 @@ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, use
102
102
 
103
103
  {/* Stats row */}
104
104
  {chat?.stats && (
105
- <div className="flex items-center gap-3 px-4 py-2 text-[10px] shrink-0" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text2)' }}>
105
+ <div className="flex items-center gap-3 px-4 py-2 text-[11px] shrink-0" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text2)' }}>
106
106
  <span>{chat.stats.totalMessages} msgs</span>
107
107
  {chat.stats.toolCalls?.length > 0 && <span>{chat.stats.toolCalls.length} tools</span>}
108
108
  {chat.stats.totalInputTokens > 0 && <span>{formatNumber(chat.stats.totalInputTokens)} in</span>}
@@ -125,7 +125,7 @@ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, use
125
125
  placeholder="Filter messages..."
126
126
  value={msgFilter}
127
127
  onChange={e => setMsgFilter(e.target.value)}
128
- className="w-full pl-7 pr-3 py-1 text-[11px] outline-none"
128
+ className="w-full pl-7 pr-3 py-1 text-[12px] outline-none"
129
129
  style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
130
130
  />
131
131
  </div>
@@ -135,10 +135,10 @@ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, use
135
135
  {/* Messages */}
136
136
  <div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-thin px-4 py-3 space-y-2">
137
137
  {loading && (
138
- <div className="text-[11px] py-12 text-center" style={{ color: 'var(--c-text3)' }}>Loading conversation...</div>
138
+ <div className="text-[12px] py-12 text-center" style={{ color: 'var(--c-text3)' }}>Loading conversation...</div>
139
139
  )}
140
140
  {!loading && chat && chat.messages.length === 0 && (
141
- <div className="text-[11px] py-12 text-center" style={{ color: 'var(--c-text3)' }}>
141
+ <div className="text-[12px] py-12 text-center" style={{ color: 'var(--c-text3)' }}>
142
142
  {chat.encrypted ? '🔒 This conversation is encrypted.' : 'No messages found.'}
143
143
  </div>
144
144
  )}
@@ -149,7 +149,7 @@ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, use
149
149
  const Icon = cfg.icon
150
150
  return (
151
151
  <div key={i} className="rounded-r px-3 py-2" style={{ borderLeft: `2px solid ${cfg.borderColor}`, background: cfg.bg }}>
152
- <div className="flex items-center gap-1.5 text-[10px] mb-1" style={{ color: 'var(--c-text2)' }}>
152
+ <div className="flex items-center gap-1.5 text-[11px] mb-1" style={{ color: 'var(--c-text2)' }}>
153
153
  <Icon size={11} />
154
154
  <span className="font-medium">{msg.role === 'user' && (username || chat?.username) ? (username || chat.username) : cfg.label}</span>
155
155
  {msg.model && <span className="font-mono" style={{ color: 'var(--c-accent)', opacity: 0.6 }}>· {msg.model}</span>}
@@ -46,7 +46,7 @@ export default function DateRangePicker({ value, onChange }) {
46
46
  <button
47
47
  key={p.label}
48
48
  onClick={() => applyPreset(p.days)}
49
- className="px-2 py-0.5 text-[10px] transition"
49
+ className="px-2 py-0.5 text-[11px] transition"
50
50
  style={{
51
51
  border: isActive ? '1px solid var(--c-accent)' : '1px solid var(--c-border)',
52
52
  color: isActive ? 'var(--c-accent)' : 'var(--c-text2)',
@@ -64,7 +64,7 @@ export default function DateRangePicker({ value, onChange }) {
64
64
  value={value?.from || ''}
65
65
  max={value?.to || today}
66
66
  onChange={e => setFrom(e.target.value)}
67
- className="px-1.5 py-0.5 text-[10px] outline-none cursor-pointer"
67
+ className="px-1.5 py-0.5 text-[11px] outline-none cursor-pointer"
68
68
  style={{
69
69
  background: 'var(--c-bg3)',
70
70
  color: 'var(--c-text)',
@@ -72,14 +72,14 @@ export default function DateRangePicker({ value, onChange }) {
72
72
  colorScheme: dark ? 'dark' : 'light',
73
73
  }}
74
74
  />
75
- <span className="text-[10px]" style={{ color: 'var(--c-text3)' }}>—</span>
75
+ <span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>—</span>
76
76
  <input
77
77
  type="date"
78
78
  value={value?.to || ''}
79
79
  min={value?.from || ''}
80
80
  max={today}
81
81
  onChange={e => setTo(e.target.value)}
82
- className="px-1.5 py-0.5 text-[10px] outline-none cursor-pointer"
82
+ className="px-1.5 py-0.5 text-[11px] outline-none cursor-pointer"
83
83
  style={{
84
84
  background: 'var(--c-bg3)',
85
85
  color: 'var(--c-text)',
@@ -92,7 +92,7 @@ export default function DateRangePicker({ value, onChange }) {
92
92
  {active && (
93
93
  <button
94
94
  onClick={() => onChange(null)}
95
- className="flex items-center gap-0.5 px-2 py-0.5 text-[10px] transition"
95
+ className="flex items-center gap-0.5 px-2 py-0.5 text-[11px] transition"
96
96
  style={{ border: '1px solid var(--c-accent)', color: 'var(--c-accent)' }}
97
97
  >
98
98
  <X size={9} /> clear
@@ -9,11 +9,11 @@ export default function EditorBreakdown({ editors, total }) {
9
9
  return (
10
10
  <div key={src} className="flex items-center gap-2">
11
11
  <EditorDot source={src} size={8} />
12
- <span className="text-[10px] w-24" style={{ color: 'var(--c-text)' }}>{editorLabel(src)}</span>
12
+ <span className="text-[11px] w-24" style={{ color: 'var(--c-text)' }}>{editorLabel(src)}</span>
13
13
  <div className="flex-1 h-2 relative" style={{ background: 'var(--c-card)' }}>
14
14
  <div className="h-full" style={{ width: `${pct}%`, background: editorColor(src), opacity: 0.7 }} />
15
15
  </div>
16
- <span className="text-[10px] w-10 text-right" style={{ color: 'var(--c-text2)' }}>{count}</span>
16
+ <span className="text-[11px] w-10 text-right" style={{ color: 'var(--c-text2)' }}>{count}</span>
17
17
  </div>
18
18
  )
19
19
  })}
@@ -5,7 +5,7 @@ export default function EditorDot({ source, showLabel = false, size = 8 }) {
5
5
  return (
6
6
  <span className="inline-flex items-center gap-1.5">
7
7
  <EditorIcon source={source} size={size} />
8
- {showLabel && <span className="text-[10px]" style={{ color: 'var(--c-text)' }}>{editorLabel(source)}</span>}
8
+ {showLabel && <span className="text-[11px]" style={{ color: 'var(--c-text)' }}>{editorLabel(source)}</span>}
9
9
  </span>
10
10
  )
11
11
  }
@@ -2,8 +2,8 @@ export default function KpiCard({ label, value, sub, onClick }) {
2
2
  return (
3
3
  <div className={`card px-3 py-2${onClick ? ' cursor-pointer hover:opacity-80 transition' : ''}`} onClick={onClick}>
4
4
  <div className="text-base font-bold" style={{ color: onClick ? 'var(--c-accent)' : 'var(--c-white)' }}>{value}</div>
5
- <div className="text-[10px]" style={{ color: 'var(--c-text2)' }}>{label}</div>
6
- {sub && <div className="text-[9px] mt-0.5" style={{ color: 'var(--c-text3)' }}>{sub}</div>}
5
+ <div className="text-[11px]" style={{ color: 'var(--c-text2)' }}>{label}</div>
6
+ {sub && <div className="text-[10px] mt-0.5" style={{ color: 'var(--c-text3)' }}>{sub}</div>}
7
7
  </div>
8
8
  )
9
9
  }
@@ -62,14 +62,14 @@ export default function LiveFeed({ onSessionClick }) {
62
62
  {/* Header */}
63
63
  <div className="flex items-center gap-2 px-3 py-2.5 shrink-0" style={{ borderBottom: '1px solid var(--c-border)' }}>
64
64
  <Radio size={12} style={{ color: '#22c55e' }} />
65
- <span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--c-text2)' }}>Live Feed</span>
65
+ <span className="text-[12px] font-medium uppercase tracking-wider" style={{ color: 'var(--c-text2)' }}>Live Feed</span>
66
66
  <span className="inline-block w-1.5 h-1.5 rounded-full pulse-dot ml-auto" style={{ background: '#22c55e' }} />
67
67
  </div>
68
68
 
69
69
  {/* Feed */}
70
70
  <div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-thin">
71
71
  {items.length === 0 && (
72
- <div className="text-[11px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>
72
+ <div className="text-[12px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>
73
73
  No recent activity
74
74
  </div>
75
75
  )}
@@ -77,7 +77,7 @@ export default function LiveFeed({ onSessionClick }) {
77
77
  {buckets.map((bucket, bi) => (
78
78
  <div key={bi}>
79
79
  {/* Time separator */}
80
- <div className="sticky top-0 px-3 py-1.5 text-[9px] font-medium uppercase tracking-wider" style={{ background: 'var(--c-bg)', color: 'var(--c-text3)', borderBottom: '1px solid var(--c-border)' }}>
80
+ <div className="sticky top-0 px-3 py-1.5 text-[10px] font-medium uppercase tracking-wider" style={{ background: 'var(--c-bg)', color: 'var(--c-text3)', borderBottom: '1px solid var(--c-border)' }}>
81
81
  {bucket.label}
82
82
  </div>
83
83
 
@@ -89,26 +89,26 @@ export default function LiveFeed({ onSessionClick }) {
89
89
  onClick={() => onSessionClick && onSessionClick(item.id, item.username)}
90
90
  >
91
91
  {/* Session name */}
92
- <div className="text-[11px] font-medium truncate mb-1" style={{ color: 'var(--c-white)' }}>
92
+ <div className="text-[12px] font-medium truncate mb-1" style={{ color: 'var(--c-white)' }}>
93
93
  {item.name || 'Untitled'}
94
94
  </div>
95
95
 
96
96
  {/* User + editor row */}
97
97
  <div className="flex items-center gap-1.5 mb-1">
98
98
  <span
99
- className="text-[9px] font-medium px-1 py-0.5 shrink-0 truncate max-w-[120px]"
99
+ className="text-[10px] font-medium px-1 py-0.5 shrink-0 truncate max-w-[120px]"
100
100
  style={{ background: 'rgba(99,102,241,0.12)', color: '#818cf8' }}
101
101
  title={item.username}
102
102
  >
103
103
  {item.username}
104
104
  </span>
105
105
  <EditorDot source={item.source} size={6} />
106
- <span className="text-[9px] truncate" style={{ color: 'var(--c-text2)' }}>{editorLabel(item.source)}</span>
107
- <span className="text-[9px] ml-auto shrink-0" style={{ color: 'var(--c-text3)' }}>{timeLabel(item.lastUpdatedAt)}</span>
106
+ <span className="text-[10px] truncate" style={{ color: 'var(--c-text2)' }}>{editorLabel(item.source)}</span>
107
+ <span className="text-[10px] ml-auto shrink-0" style={{ color: 'var(--c-text3)' }}>{timeLabel(item.lastUpdatedAt)}</span>
108
108
  </div>
109
109
 
110
110
  {/* Meta row */}
111
- <div className="flex items-center gap-2 text-[9px]" style={{ color: 'var(--c-text3)' }}>
111
+ <div className="flex items-center gap-2 text-[10px]" style={{ color: 'var(--c-text3)' }}>
112
112
  {item.totalMessages > 0 && (
113
113
  <span className="flex items-center gap-0.5">
114
114
  <MessageSquare size={8} /> {item.totalMessages}
@@ -1,6 +1,7 @@
1
1
  import { useState } from 'react'
2
2
  import { Lock } from 'lucide-react'
3
3
  import { login } from '../lib/api'
4
+ import AnimatedLogo from './AnimatedLogo'
4
5
 
5
6
  export default function LoginScreen({ onSuccess }) {
6
7
  const [password, setPassword] = useState('')
@@ -31,13 +32,14 @@ export default function LoginScreen({ onSuccess }) {
31
32
  >
32
33
  <Lock size={18} style={{ color: '#818cf8' }} />
33
34
  </div>
34
- <div className="text-sm font-bold" style={{ color: 'var(--c-white)' }}>
35
- npx agentlytics
36
- <span className="ml-1.5 text-[9px] font-medium px-1.5 py-0.5" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>
35
+ <div className="flex items-center gap-1.5 text-sm font-bold" style={{ color: 'var(--c-white)' }}>
36
+ <AnimatedLogo size={16} />
37
+ Agentlytics
38
+ <span className="text-[10px] font-medium px-1.5 py-0.5" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>
37
39
  relay
38
40
  </span>
39
41
  </div>
40
- <div className="text-[10px] mt-1" style={{ color: 'var(--c-text3)' }}>
42
+ <div className="text-[11px] mt-1" style={{ color: 'var(--c-text3)' }}>
41
43
  This relay is password-protected
42
44
  </div>
43
45
  </div>
@@ -57,12 +59,12 @@ export default function LoginScreen({ onSuccess }) {
57
59
  }}
58
60
  />
59
61
  {error && (
60
- <div className="text-[10px]" style={{ color: '#f87171' }}>{error}</div>
62
+ <div className="text-[11px]" style={{ color: '#f87171' }}>{error}</div>
61
63
  )}
62
64
  <button
63
65
  type="submit"
64
66
  disabled={loading || !password}
65
- className="w-full py-2 text-[11px] font-medium rounded transition"
67
+ className="w-full py-2 text-[12px] font-medium rounded transition"
66
68
  style={{
67
69
  background: '#6366f1',
68
70
  color: '#fff',
@@ -52,7 +52,7 @@ export function ToolArgsDiff({ args }) {
52
52
  const oldLines = (old || '').split('\n').slice(0, maxLines)
53
53
  const newLines = (nw || '').split('\n').slice(0, maxLines)
54
54
  return (
55
- <div className="mt-1.5 text-[9px] font-mono overflow-x-auto" style={{ border: '1px solid var(--c-border)' }}>
55
+ <div className="mt-1.5 text-[10px] font-mono overflow-x-auto" style={{ border: '1px solid var(--c-border)' }}>
56
56
  {(args.file_path || args.TargetFile) && (
57
57
  <div className="px-2 py-0.5" style={{ background: 'var(--c-code-bg)', color: 'var(--c-text)' }}>{args.file_path || args.TargetFile}</div>
58
58
  )}
@@ -85,7 +85,7 @@ export function ToolArgsDetail({ args }) {
85
85
  const query = args.Query || args.query || args.search_term || null
86
86
  const url = args.Url || args.url || null
87
87
  return (
88
- <div className="mt-1.5 text-[9px] font-mono overflow-x-auto" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
88
+ <div className="mt-1.5 text-[10px] font-mono overflow-x-auto" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
89
89
  {file && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>file: {file}</div>}
90
90
  {cmd && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>cmd: {cmd}</div>}
91
91
  {query && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>query: {query}</div>}
@@ -101,7 +101,7 @@ export function ToolCallBlock({ name, args, detail }) {
101
101
  const [open, setOpen] = useState(false)
102
102
  const hasDetail = detail && Object.keys(detail).length > 0
103
103
  return (
104
- <div className="my-1 px-2.5 py-1.5 text-[10px]" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
104
+ <div className="my-1 px-2.5 py-1.5 text-[11px]" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
105
105
  <div className="flex items-center gap-2 cursor-pointer" onClick={() => hasDetail && setOpen(!open)}>
106
106
  {hasDetail
107
107
  ? (open ? <ChevronDown size={10} style={{ color: '#a78bfa' }} /> : <ChevronRight size={10} style={{ color: '#a78bfa' }} />)
@@ -121,13 +121,13 @@ export function ToolResultBlock({ name, preview }) {
121
121
  const isNoisy = preview.length > 120 || preview.startsWith('{') || preview.includes('contentId')
122
122
  const short = isNoisy ? `${name} completed` : preview.substring(0, 120)
123
123
  return (
124
- <div className="my-1 px-2.5 py-1.5 text-[10px]" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
124
+ <div className="my-1 px-2.5 py-1.5 text-[11px]" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
125
125
  <div className="flex items-center gap-2 cursor-pointer" onClick={() => preview && setOpen(!open)}>
126
126
  <CheckCircle size={10} style={{ color: '#34d399' }} />
127
127
  <span className="truncate" style={{ color: 'var(--c-text)' }}>{short}</span>
128
128
  {isNoisy && preview && <span style={{ color: 'var(--c-text3)' }}>{open ? '[-]' : '[+]'}</span>}
129
129
  </div>
130
- {open && <pre className="mt-1 text-[9px] overflow-x-auto whitespace-pre-wrap break-all" style={{ color: 'var(--c-text2)' }}>{preview}</pre>}
130
+ {open && <pre className="mt-1 text-[10px] overflow-x-auto whitespace-pre-wrap break-all" style={{ color: 'var(--c-text2)' }}>{preview}</pre>}
131
131
  </div>
132
132
  )
133
133
  }
@@ -9,15 +9,15 @@ export default function ModelBreakdown({ models }) {
9
9
  return (
10
10
  <div key={name} className="flex items-center gap-2">
11
11
  <Cpu size={10} style={{ color: '#818cf8' }} />
12
- <span className="text-[10px] truncate w-40" style={{ color: 'var(--c-text)' }}>{name}</span>
12
+ <span className="text-[11px] truncate w-40" style={{ color: 'var(--c-text)' }}>{name}</span>
13
13
  <div className="flex-1 h-2 relative" style={{ background: 'var(--c-card)' }}>
14
14
  <div className="h-full" style={{ width: `${pct}%`, background: '#6366f1', opacity: 0.5 }} />
15
15
  </div>
16
- <span className="text-[10px] w-10 text-right" style={{ color: 'var(--c-text2)' }}>{count}</span>
16
+ <span className="text-[11px] w-10 text-right" style={{ color: 'var(--c-text2)' }}>{count}</span>
17
17
  </div>
18
18
  )
19
19
  })}
20
- {models.length === 0 && <div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>No model data</div>}
20
+ {models.length === 0 && <div className="text-[11px]" style={{ color: 'var(--c-text3)' }}>No model data</div>}
21
21
  </div>
22
22
  )
23
23
  }
@@ -1,3 +1,3 @@
1
1
  export default function SectionTitle({ children }) {
2
- return <h3 className="text-[10px] font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--c-text2)' }}>{children}</h3>
2
+ return <h3 className="text-[11px] font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--c-text2)' }}>{children}</h3>
3
3
  }
package/ui/src/index.css CHANGED
@@ -36,7 +36,7 @@ body {
36
36
  background: var(--c-bg);
37
37
  color: var(--c-text);
38
38
  min-height: 100vh;
39
- font-size: 11.5px;
39
+ font-size: 13px;
40
40
  line-height: 1.5;
41
41
  }
42
42
 
package/ui/src/lib/api.js CHANGED
@@ -127,6 +127,26 @@ export async function fetchDashboardStats(params = {}) {
127
127
  return res.json();
128
128
  }
129
129
 
130
+ export async function fetchCostAnalytics(params = {}) {
131
+ const q = new URLSearchParams();
132
+ if (params.editor) q.set('editor', params.editor);
133
+ appendDateParams(q, params);
134
+ const qs = q.toString();
135
+ const res = await fetch(`${BASE}/api/cost-analytics${qs ? '?' + qs : ''}`);
136
+ return res.json();
137
+ }
138
+
139
+ export async function fetchCosts(params = {}) {
140
+ const q = new URLSearchParams();
141
+ if (params.editor) q.set('editor', params.editor);
142
+ if (params.folder) q.set('folder', params.folder);
143
+ if (params.chatId) q.set('chatId', params.chatId);
144
+ appendDateParams(q, params);
145
+ const qs = q.toString();
146
+ const res = await fetch(`${BASE}/api/costs${qs ? '?' + qs : ''}`);
147
+ return res.json();
148
+ }
149
+
130
150
  export async function executeQuery(sql) {
131
151
  const res = await fetch(`${BASE}/api/query`, {
132
152
  method: 'POST',