agentlytics 0.1.2 → 0.1.5

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 CHANGED
@@ -193,7 +193,7 @@ function analyzeAndStore(chat) {
193
193
  function scanAll(onProgress, opts = {}) {
194
194
  const force = opts.force || false;
195
195
  if (force || opts.resetCaches) resetCaches();
196
- const chats = getAllChats();
196
+ const chats = opts.chats || getAllChats();
197
197
  const total = chats.length;
198
198
  let scanned = 0;
199
199
  let analyzed = 0;
@@ -602,6 +602,9 @@ function safeParseJson(s) {
602
602
  function resetAndRescan(onProgress) {
603
603
  if (db) db.close();
604
604
  if (fs.existsSync(CACHE_DB)) fs.unlinkSync(CACHE_DB);
605
+ for (const suffix of ['-wal', '-shm']) {
606
+ if (fs.existsSync(CACHE_DB + suffix)) fs.unlinkSync(CACHE_DB + suffix);
607
+ }
605
608
  initDb();
606
609
  return scanAll(onProgress);
607
610
  }
@@ -676,6 +679,9 @@ async function scanAllAsync(onProgress) {
676
679
  async function resetAndRescanAsync(onProgress) {
677
680
  if (db) db.close();
678
681
  if (fs.existsSync(CACHE_DB)) fs.unlinkSync(CACHE_DB);
682
+ for (const suffix of ['-wal', '-shm']) {
683
+ if (fs.existsSync(CACHE_DB + suffix)) fs.unlinkSync(CACHE_DB + suffix);
684
+ }
679
685
  initDb();
680
686
  return scanAllAsync(onProgress);
681
687
  }
package/index.js CHANGED
@@ -123,9 +123,11 @@ function getLocalIp() {
123
123
  return 'localhost';
124
124
  }
125
125
 
126
+ // ── ASCII banner ─────────────────────────────────────────
127
+ const c1 = chalk.hex('#818cf8'), c2 = chalk.hex('#f472b6'), c3 = chalk.hex('#34d399'), c4 = chalk.hex('#fbbf24');
126
128
  console.log('');
127
- console.log(chalk.bold('Agentlytics'));
128
- console.log(chalk.dim(' Comprehensive analytics for your AI coding agents'));
129
+ console.log(` ${c1('(● ●)')} ${c2('[● ●]')} ${chalk.bold('Agentlytics')}`);
130
+ console.log(` ${c3('{● ●}')} ${c4('<● ●>')} ${chalk.dim('Unified analytics for your AI coding agents')}`);
129
131
  if (collectOnly) console.log(chalk.cyan(' ⟳ Collect-only mode (no server)'));
130
132
  console.log('');
131
133
 
@@ -164,6 +166,10 @@ if (noCache) {
164
166
  const cacheDb = path.join(os.homedir(), '.agentlytics', 'cache.db');
165
167
  if (fs.existsSync(cacheDb)) {
166
168
  fs.unlinkSync(cacheDb);
169
+ // Remove WAL/SHM journal files to avoid SQLITE_IOERR_SHORT_READ
170
+ for (const suffix of ['-wal', '-shm']) {
171
+ if (fs.existsSync(cacheDb + suffix)) fs.unlinkSync(cacheDb + suffix);
172
+ }
167
173
  console.log(chalk.yellow(' ⟳ Cache cleared (--no-cache)'));
168
174
  }
169
175
  }
@@ -204,18 +210,74 @@ const WINDSURF_VARIANTS = [
204
210
  })();
205
211
 
206
212
  // Initialize cache DB
207
- console.log(chalk.dim(' Initializing cache database...'));
208
213
  cache.initDb();
209
214
 
210
- // Scan all editors and populate cache
211
- console.log(chalk.dim(' Scanning editors: Cursor, Windsurf, Claude Code, VS Code, Zed, Antigravity, OpenCode, Codex, Gemini CLI, Copilot CLI, Cursor Agent, Command Code'));
215
+ // ── Detect editors & collect sessions ───────────────────────
216
+ const { editors: editorModules } = require('./editors');
217
+ const EDITOR_DISPLAY = [
218
+ ['cursor', 'Cursor'],
219
+ ['windsurf', 'Windsurf'],
220
+ ['windsurf-next', 'Windsurf Next'],
221
+ ['antigravity', 'Antigravity'],
222
+ ['claude-code', 'Claude Code'],
223
+ ['vscode', 'VS Code'],
224
+ ['vscode-insiders', 'VS Code Insiders'],
225
+ ['zed', 'Zed'],
226
+ ['opencode', 'OpenCode'],
227
+ ['codex', 'Codex'],
228
+ ['gemini-cli', 'Gemini CLI'],
229
+ ['copilot-cli', 'Copilot CLI'],
230
+ ['cursor-agent', 'Cursor Agent'],
231
+ ['commandcode', 'Command Code'],
232
+ ];
233
+
234
+ console.log(chalk.dim(' Looking for AI coding agents...'));
235
+ const allChats = [];
236
+ for (const editor of editorModules) {
237
+ try {
238
+ const chats = editor.getChats();
239
+ allChats.push(...chats);
240
+ } catch { /* skip broken adapters */ }
241
+ }
242
+ allChats.sort((a, b) => (b.lastUpdatedAt || b.createdAt || 0) - (a.lastUpdatedAt || a.createdAt || 0));
243
+
244
+ // Count per source
245
+ const bySource = {};
246
+ for (const chat of allChats) bySource[chat.source] = (bySource[chat.source] || 0) + 1;
247
+
248
+ for (const [src, label] of EDITOR_DISPLAY) {
249
+ const count = bySource[src] || 0;
250
+ if (count > 0) {
251
+ console.log(` ${chalk.green('✓')} ${chalk.bold(label.padEnd(18))} ${chalk.dim(`${count} session${count === 1 ? '' : 's'}`)}`);
252
+ } else {
253
+ console.log(` ${chalk.dim('–')} ${chalk.dim(label.padEnd(18) + '–')}`);
254
+ }
255
+ }
256
+ console.log('');
257
+
258
+ // ── Analyze sessions with robot animation ──────────────────
259
+ const logUpdate = require('log-update');
260
+ const BOT_STYLES = [
261
+ { l: '(', r: ')', color: '#818cf8' },
262
+ { l: '[', r: ']', color: '#f472b6' },
263
+ { l: '{', r: '}', color: '#34d399' },
264
+ { l: '<', r: '>', color: '#fbbf24' },
265
+ ];
266
+ let tick = 0;
267
+
212
268
  const startTime = Date.now();
213
- const result = cache.scanAll((progress) => {
214
- process.stdout.write(chalk.dim(`\r Scanning: ${progress.scanned}/${progress.total} chats (${progress.analyzed} analyzed, ${progress.skipped} cached)`));
215
- });
269
+ const result = cache.scanAll((p) => {
270
+ tick++;
271
+ if (tick % 5 !== 0) return;
272
+ const frame = Math.floor(tick / 40);
273
+ const b = BOT_STYLES[frame % 4];
274
+ const dots = '.'.repeat((Math.floor(tick / 10) % 3) + 1).padEnd(3);
275
+ logUpdate(` ${chalk.hex(b.color)(`${b.l}● ●${b.r}`)} ${chalk.dim(`Analyzing${dots} ${p.scanned}/${p.total}`)}`);
276
+ }, { chats: allChats });
216
277
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
217
- console.log('');
218
- console.log(chalk.green(` ✓ Cache ready: ${result.total} chats, ${result.analyzed} analyzed, ${result.skipped} cached (${elapsed}s)`));
278
+ const allFaces = BOT_STYLES.map(b => chalk.hex(b.color)(`${b.l}● ●${b.r}`)).join(' ');
279
+ logUpdate(` ${allFaces} ${chalk.green(`✓ ${result.analyzed} analyzed, ${result.skipped} cached (${elapsed}s)`)}`);
280
+ logUpdate.done();
219
281
  console.log('');
220
282
 
221
283
  // In collect-only mode, exit after cache is built
@@ -231,7 +293,12 @@ const app = require('./server');
231
293
  app.listen(PORT, () => {
232
294
  const url = `http://localhost:${PORT}`;
233
295
  console.log(chalk.green(` ✓ Dashboard ready at ${chalk.bold.white(url)}`));
234
- console.log(chalk.dim(` Press Ctrl+C to stop\n`));
296
+ console.log('');
297
+ console.log(chalk.dim(' 💡 Share sessions with your team:'));
298
+ console.log(chalk.dim(` npx agentlytics --relay Start a relay server`));
299
+ console.log(chalk.dim(` npx agentlytics --join <host:port> --username Join a relay server`));
300
+ console.log('');
301
+ console.log(chalk.dim(' Press Ctrl+C to stop\n'));
235
302
 
236
303
  // Auto-open browser
237
304
  const open = require('open');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlytics",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "description": "Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravity, OpenCode, Command Code",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -55,6 +55,7 @@
55
55
  "commander": "^14.0.3",
56
56
  "express": "^4.22.1",
57
57
  "inquirer": "^13.3.0",
58
+ "log-update": "^4.0.0",
58
59
  "open": "^8.4.2"
59
60
  }
60
61
  }
package/ui/src/App.jsx CHANGED
@@ -3,6 +3,7 @@ import { Routes, Route, NavLink } from 'react-router-dom'
3
3
  import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, 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'
@@ -46,16 +47,16 @@ export default function App() {
46
47
  }, [mode, authed])
47
48
 
48
49
  const refreshOverview = useCallback(() => {
49
- fetchOverview().then(setOverview)
50
+ fetchOverview().then(setOverview).catch(() => {})
50
51
  }, [])
51
52
 
52
53
  useEffect(() => {
53
- refreshOverview()
54
- }, [])
54
+ if (mode === 'local') refreshOverview()
55
+ }, [mode])
55
56
 
56
57
  // Live mode: refetch overview every 60s
57
58
  useEffect(() => {
58
- if (live) {
59
+ if (live && mode === 'local') {
59
60
  liveRef.current = setInterval(() => {
60
61
  refreshOverview()
61
62
  }, 60000)
@@ -97,8 +98,9 @@ export default function App() {
97
98
  return (
98
99
  <div className="min-h-screen">
99
100
  <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>}
101
+ <span className="flex items-center gap-1.5 text-xs font-bold tracking-tight" style={{ color: 'var(--c-white)' }}>
102
+ <AnimatedLogo size={18} />
103
+ 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
104
  </span>
103
105
  <nav className="flex gap-0.5 ml-2">
104
106
  {nav.map(({ to, icon: Icon, label }) => (
@@ -107,7 +109,7 @@ export default function App() {
107
109
  to={to}
108
110
  end={to === '/'}
109
111
  className={({ isActive }) =>
110
- `flex items-center gap-1.5 px-2.5 py-1 text-[11px] rounded transition ${
112
+ `flex items-center gap-1.5 px-2.5 py-1 text-[12px] rounded transition ${
111
113
  isActive ? 'bg-[var(--c-card)] text-[var(--c-white)]' : 'text-[var(--c-text2)] hover:text-[var(--c-white)]'
112
114
  }`
113
115
  }
@@ -122,7 +124,7 @@ export default function App() {
122
124
  <>
123
125
  <button
124
126
  onClick={() => setLive(!live)}
125
- className="flex items-center gap-1.5 px-2 py-0.5 text-[10px] transition"
127
+ className="flex items-center gap-1.5 px-2 py-0.5 text-[11px] transition"
126
128
  style={{
127
129
  color: live ? '#22c55e' : 'var(--c-text3)',
128
130
  border: live ? '1px solid rgba(34,197,94,0.3)' : '1px solid var(--c-border)',
@@ -139,7 +141,7 @@ export default function App() {
139
141
  <button
140
142
  onClick={handleRefetch}
141
143
  disabled={!!refetchState}
142
- className="flex items-center gap-1 px-2 py-0.5 text-[10px] rounded transition hover:bg-[var(--c-card)]"
144
+ className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded transition hover:bg-[var(--c-card)]"
143
145
  style={{ color: 'var(--c-text2)', border: '1px solid var(--c-border)' }}
144
146
  title="Clear cache and rescan all editors"
145
147
  >
@@ -148,7 +150,7 @@ export default function App() {
148
150
  ? `Refetching (${refetchState.scanned}/${refetchState.total})...`
149
151
  : 'Refetch'}
150
152
  </button>
151
- <span className="text-[10px]" style={{ color: 'var(--c-text2)' }}>
153
+ <span className="text-[11px]" style={{ color: 'var(--c-text2)' }}>
152
154
  {overview ? `${overview.totalChats} sessions` : '...'}
153
155
  </span>
154
156
  </>
@@ -156,7 +158,7 @@ export default function App() {
156
158
  {isRelay && (
157
159
  <button
158
160
  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)]"
161
+ className="flex items-center gap-1.5 px-2 py-0.5 text-[11px] transition hover:bg-[var(--c-card)]"
160
162
  style={{ color: '#818cf8', border: '1px solid var(--c-border)' }}
161
163
  title="MCP Connection"
162
164
  >
@@ -176,13 +178,13 @@ export default function App() {
176
178
  </header>
177
179
 
178
180
  {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' }}>
181
+ <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
182
  <AlertTriangle size={12} />
181
183
  <span>Windsurf, Windsurf Next, and Antigravity require their app to be running during refetch — otherwise their sessions won't be detected.</span>
182
184
  </div>
183
185
  )}
184
186
 
185
- <main className="p-4 max-w-[1400px] mx-auto">
187
+ <main className={isRelay ? 'px-0' : 'p-4 max-w-[1400px] mx-auto'}>
186
188
  {mode === null ? (
187
189
  <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading...</div>
188
190
  ) : isRelay ? (
@@ -205,7 +207,7 @@ export default function App() {
205
207
  )}
206
208
  </main>
207
209
 
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)' }}>
210
+ <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
211
  <div className="flex items-center gap-3">
210
212
  <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
213
  <Github size={11} />
@@ -237,9 +239,9 @@ export default function App() {
237
239
  <button onClick={() => setMcpOpen(false)} className="text-[18px] leading-none px-1 hover:opacity-70 transition" style={{ color: 'var(--c-text3)' }}>&times;</button>
238
240
  </div>
239
241
 
240
- <div className="text-[11px] font-medium mb-1.5" style={{ color: 'var(--c-white)' }}>MCP Config</div>
242
+ <div className="text-[12px] font-medium mb-1.5" style={{ color: 'var(--c-white)' }}>MCP Config</div>
241
243
  <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>
244
+ <div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>Add to your AI client's MCP settings</div>
243
245
  <button
244
246
  onClick={() => {
245
247
  const json = JSON.stringify({ "mcpServers": { "agentlytics": { "url": `${window.location.origin}/mcp` } } }, null, 2)
@@ -247,21 +249,21 @@ export default function App() {
247
249
  setMcpCopied(true)
248
250
  setTimeout(() => setMcpCopied(false), 2000)
249
251
  }}
250
- className="flex items-center gap-1 px-1.5 py-0.5 text-[9px] transition hover:bg-[var(--c-bg3)]"
252
+ className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] transition hover:bg-[var(--c-bg3)]"
251
253
  style={{ border: '1px solid var(--c-border)', color: mcpCopied ? '#22c55e' : 'var(--c-text2)' }}
252
254
  >
253
255
  {mcpCopied ? <><Check size={9} /> Copied</> : <><Copy size={9} /> Copy</>}
254
256
  </button>
255
257
  </div>
256
258
  <pre
257
- className="text-[10px] px-3 py-2 overflow-x-auto mb-4"
259
+ className="text-[11px] px-3 py-2 overflow-x-auto mb-4"
258
260
  style={{ background: 'var(--c-bg3)', border: '1px solid var(--c-border)', color: 'var(--c-text)', fontFamily: 'JetBrains Mono, monospace', lineHeight: 1.6 }}
259
261
  >{`{\n "mcpServers": {\n "agentlytics": {\n "url": "${window.location.origin}/mcp"\n }\n }\n}`}</pre>
260
262
 
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>
263
+ <div className="text-[12px] font-medium mb-1.5" style={{ color: 'var(--c-white)' }}>Join Command</div>
264
+ <div className="text-[10px] mb-1" style={{ color: 'var(--c-text3)' }}>Share with your team to start syncing sessions</div>
263
265
  <pre
264
- className="text-[10px] px-3 py-2 overflow-x-auto"
266
+ className="text-[11px] px-3 py-2 overflow-x-auto"
265
267
  style={{ background: 'var(--c-bg3)', border: '1px solid var(--c-border)', color: 'var(--c-text)', fontFamily: 'JetBrains Mono, monospace', lineHeight: 1.6 }}
266
268
  >{`cd /path/to/your-project\nRELAY_PASSWORD=${relayPassword || '<pass>'} npx agentlytics --join ${window.location.host}`}</pre>
267
269
  </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}