agentlytics 0.1.3 → 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 +7 -1
- package/index.js +78 -11
- package/package.json +2 -1
- package/ui/src/App.jsx +18 -16
- package/ui/src/components/ActivityHeatmap.jsx +3 -3
- package/ui/src/components/AnimatedLogo.jsx +96 -0
- package/ui/src/components/ChatSidebar.jsx +7 -7
- package/ui/src/components/DateRangePicker.jsx +5 -5
- package/ui/src/components/EditorBreakdown.jsx +2 -2
- package/ui/src/components/EditorDot.jsx +1 -1
- package/ui/src/components/KpiCard.jsx +2 -2
- package/ui/src/components/LiveFeed.jsx +8 -8
- package/ui/src/components/LoginScreen.jsx +8 -6
- package/ui/src/components/MessageRenderer.jsx +5 -5
- package/ui/src/components/ModelBreakdown.jsx +3 -3
- package/ui/src/components/SectionTitle.jsx +1 -1
- package/ui/src/index.css +1 -1
- package/ui/src/pages/Compare.jsx +18 -18
- package/ui/src/pages/Dashboard.jsx +14 -14
- package/ui/src/pages/DeepAnalysis.jsx +27 -27
- package/ui/src/pages/ProjectDetail.jsx +11 -11
- package/ui/src/pages/Projects.jsx +5 -5
- package/ui/src/pages/RelayDashboard.jsx +29 -29
- package/ui/src/pages/RelaySessionDetail.jsx +1 -1
- package/ui/src/pages/RelayUserDetail.jsx +18 -18
- package/ui/src/pages/Sessions.jsx +19 -19
- package/ui/src/pages/SqlViewer.jsx +14 -14
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('
|
|
128
|
-
console.log(chalk.dim('
|
|
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
|
-
//
|
|
211
|
-
|
|
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((
|
|
214
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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(
|
|
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.
|
|
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'
|
|
@@ -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
|
-
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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,7 +178,7 @@ 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-[
|
|
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>
|
|
@@ -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-[
|
|
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)' }}>×</button>
|
|
238
240
|
</div>
|
|
239
241
|
|
|
240
|
-
<div className="text-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
262
|
-
<div className="text-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
6
|
-
{sub && <div className="text-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
107
|
-
<span className="text-[
|
|
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-[
|
|
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}
|