agentlytics 0.1.6 → 0.1.7

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
@@ -293,6 +293,13 @@ function scanAll(onProgress, opts = {}) {
293
293
  // Query helpers (used by server.js)
294
294
  // ============================================================
295
295
 
296
+ // Returns { sql, params } for excluding hidden folders
297
+ function hiddenFolderFilter(opts, colName = 'folder') {
298
+ if (!opts.hiddenFolders || opts.hiddenFolders.length === 0) return { sql: '', params: [] };
299
+ const placeholders = opts.hiddenFolders.map(() => '?').join(',');
300
+ return { sql: ` AND (${colName} IS NULL OR ${colName} NOT IN (${placeholders}))`, params: [...opts.hiddenFolders] };
301
+ }
302
+
296
303
  function getCachedChats(opts = {}) {
297
304
  let sql = `SELECT c.*,
298
305
  cs.models AS _models,
@@ -301,6 +308,8 @@ function getCachedChats(opts = {}) {
301
308
  cs.total_user_chars AS _uChars, cs.total_assistant_chars AS _aChars
302
309
  FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id WHERE 1=1`;
303
310
  const params = [];
311
+ const hf = hiddenFolderFilter(opts, 'c.folder');
312
+ if (hf.sql) { sql += hf.sql; params.push(...hf.params); }
304
313
  if (opts.editor) { sql += ' AND c.source LIKE ?'; params.push(`%${opts.editor}%`); }
305
314
  if (opts.folder) { sql += ' AND c.folder LIKE ?'; params.push(`%${opts.folder}%`); }
306
315
  if (opts.named !== false) { sql += ' AND (c.name IS NOT NULL OR c.bubble_count > 0)'; }
@@ -335,6 +344,8 @@ function getCachedChats(opts = {}) {
335
344
  function countCachedChats(opts = {}) {
336
345
  let sql = 'SELECT COUNT(*) as cnt FROM chats WHERE 1=1';
337
346
  const params = [];
347
+ const hf = hiddenFolderFilter(opts);
348
+ if (hf.sql) { sql += hf.sql; params.push(...hf.params); }
338
349
  if (opts.editor) { sql += ' AND source LIKE ?'; params.push(`%${opts.editor}%`); }
339
350
  if (opts.folder) { sql += ' AND folder LIKE ?'; params.push(`%${opts.folder}%`); }
340
351
  if (opts.named !== false) { sql += ' AND (name IS NOT NULL OR bubble_count > 0)'; }
@@ -347,6 +358,8 @@ function getCachedOverview(opts = {}) {
347
358
  // Build conditions dynamically to support editor + date range filters
348
359
  const conditions = [];
349
360
  const params = [];
361
+ const hf = hiddenFolderFilter(opts);
362
+ if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
350
363
  if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
351
364
  if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
352
365
  if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
@@ -411,6 +424,8 @@ function getCachedOverview(opts = {}) {
411
424
  function getCachedDailyActivity(opts = {}) {
412
425
  const conditions = [];
413
426
  const params = [];
427
+ const hf = hiddenFolderFilter(opts);
428
+ if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
414
429
  if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
415
430
  if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
416
431
  if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
@@ -441,6 +456,8 @@ function getCachedDailyActivity(opts = {}) {
441
456
  function getCachedDeepAnalytics(opts = {}) {
442
457
  let sql = 'SELECT cs.* FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id WHERE 1=1';
443
458
  const params = [];
459
+ const hf = hiddenFolderFilter(opts, 'c.folder');
460
+ if (hf.sql) { sql += hf.sql; params.push(...hf.params); }
444
461
  if (opts.editor) { sql += ' AND c.source LIKE ?'; params.push(`%${opts.editor}%`); }
445
462
  if (opts.folder) { sql += ' AND c.folder = ?'; params.push(opts.folder); }
446
463
  if (opts.dateFrom) { sql += ' AND COALESCE(c.last_updated_at, c.created_at) >= ?'; params.push(opts.dateFrom); }
@@ -542,6 +559,10 @@ function getCachedProjects(opts = {}) {
542
559
  // Build date filter
543
560
  let dateFilter = '';
544
561
  const dateParams = [];
562
+ if (!opts.includeHidden) {
563
+ const hf = hiddenFolderFilter(opts);
564
+ if (hf.sql) { dateFilter += hf.sql; dateParams.push(...hf.params); }
565
+ }
545
566
  if (opts.dateFrom) { dateFilter += ' AND COALESCE(last_updated_at, created_at) >= ?'; dateParams.push(opts.dateFrom); }
546
567
  if (opts.dateTo) { dateFilter += ' AND COALESCE(last_updated_at, created_at) <= ?'; dateParams.push(opts.dateTo); }
547
568
 
@@ -745,6 +766,8 @@ function getCachedDashboardStats(opts = {}) {
745
766
  // Build conditions dynamically to support editor + date range filters
746
767
  const conditions = [];
747
768
  const params = [];
769
+ const hf = hiddenFolderFilter(opts);
770
+ if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
748
771
  if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
749
772
  if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
750
773
  if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
@@ -1103,6 +1126,8 @@ function estimateCosts(whereClause = '', params = []) {
1103
1126
  function getCostBreakdown(opts = {}) {
1104
1127
  let whereClause = '';
1105
1128
  const params = [];
1129
+ const hf = hiddenFolderFilter(opts, 'c.folder');
1130
+ if (hf.sql) { whereClause += hf.sql; params.push(...hf.params); }
1106
1131
  if (opts.editor) { whereClause += ' AND c.source LIKE ?'; params.push(`%${opts.editor}%`); }
1107
1132
  if (opts.folder) { whereClause += ' AND c.folder = ?'; params.push(opts.folder); }
1108
1133
  if (opts.dateFrom) { whereClause += ' AND COALESCE(c.last_updated_at, c.created_at) >= ?'; params.push(opts.dateFrom); }
@@ -1114,6 +1139,8 @@ function getCostBreakdown(opts = {}) {
1114
1139
  function getCostAnalytics(opts = {}) {
1115
1140
  const conditions = [];
1116
1141
  const params = [];
1142
+ const hf = hiddenFolderFilter(opts, 'c.folder');
1143
+ if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
1117
1144
  if (opts.editor) { conditions.push('c.source LIKE ?'); params.push(`%${opts.editor}%`); }
1118
1145
  if (opts.dateFrom) { conditions.push('COALESCE(c.last_updated_at, c.created_at) >= ?'); params.push(opts.dateFrom); }
1119
1146
  if (opts.dateTo) { conditions.push('COALESCE(c.last_updated_at, c.created_at) <= ?'); params.push(opts.dateTo); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlytics",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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": {
package/server.js CHANGED
@@ -1,5 +1,7 @@
1
1
  const express = require('express');
2
2
  const path = require('path');
3
+ const fs = require('fs');
4
+ const os = require('os');
3
5
  const cache = require('./cache');
4
6
  const { generateShareSvg } = require('./share-image');
5
7
 
@@ -7,6 +9,26 @@ const app = express();
7
9
  app.use(express.json());
8
10
  app.use(express.static(path.join(__dirname, 'public')));
9
11
 
12
+ // ============================================================
13
+ // Config: ~/.agentlytics/config.json
14
+ // ============================================================
15
+
16
+ const CONFIG_PATH = path.join(os.homedir(), '.agentlytics', 'config.json');
17
+
18
+ function readConfig() {
19
+ try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return {}; }
20
+ }
21
+
22
+ function writeConfig(config) {
23
+ const dir = path.dirname(CONFIG_PATH);
24
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
25
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
26
+ }
27
+
28
+ function getHiddenFolders() {
29
+ return readConfig().hiddenProjects || [];
30
+ }
31
+
10
32
  // ============================================================
11
33
  // API endpoints — all reads from SQLite cache
12
34
  // ============================================================
@@ -25,7 +47,7 @@ app.get('/api/mode', (req, res) => {
25
47
 
26
48
  app.get('/api/overview', (req, res) => {
27
49
  try {
28
- const opts = { editor: req.query.editor || null, ...parseDateOpts(req.query) };
50
+ const opts = { editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: getHiddenFolders() };
29
51
  res.json(cache.getCachedOverview(opts));
30
52
  } catch (err) {
31
53
  res.status(500).json({ error: err.message });
@@ -34,7 +56,7 @@ app.get('/api/overview', (req, res) => {
34
56
 
35
57
  app.get('/api/daily-activity', (req, res) => {
36
58
  try {
37
- const opts = { editor: req.query.editor || null, ...parseDateOpts(req.query) };
59
+ const opts = { editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: getHiddenFolders() };
38
60
  res.json(cache.getCachedDailyActivity(opts));
39
61
  } catch (err) {
40
62
  res.status(500).json({ error: err.message });
@@ -50,6 +72,7 @@ app.get('/api/chats', (req, res) => {
50
72
  limit: req.query.limit ? parseInt(req.query.limit) : 200,
51
73
  offset: req.query.offset ? parseInt(req.query.offset) : 0,
52
74
  ...parseDateOpts(req.query),
75
+ hiddenFolders: getHiddenFolders(),
53
76
  };
54
77
  const total = cache.countCachedChats(opts);
55
78
  const rows = cache.getCachedChats(opts);
@@ -131,7 +154,7 @@ app.get('/api/chats/:id/markdown', (req, res) => {
131
154
 
132
155
  app.get('/api/projects', (req, res) => {
133
156
  try {
134
- res.json(cache.getCachedProjects(parseDateOpts(req.query)));
157
+ res.json(cache.getCachedProjects({ ...parseDateOpts(req.query), hiddenFolders: getHiddenFolders() }));
135
158
  } catch (err) {
136
159
  res.status(500).json({ error: err.message });
137
160
  }
@@ -144,6 +167,7 @@ app.get('/api/deep-analytics', (req, res) => {
144
167
  folder: req.query.folder || null,
145
168
  limit: Math.min(parseInt(req.query.limit) || 500, 5000),
146
169
  ...parseDateOpts(req.query),
170
+ hiddenFolders: getHiddenFolders(),
147
171
  };
148
172
  res.json(cache.getCachedDeepAnalytics(opts));
149
173
  } catch (err) {
@@ -153,7 +177,7 @@ app.get('/api/deep-analytics', (req, res) => {
153
177
 
154
178
  app.get('/api/dashboard-stats', (req, res) => {
155
179
  try {
156
- const opts = { editor: req.query.editor || null, ...parseDateOpts(req.query) };
180
+ const opts = { editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: getHiddenFolders() };
157
181
  res.json(cache.getCachedDashboardStats(opts));
158
182
  } catch (err) {
159
183
  res.status(500).json({ error: err.message });
@@ -165,6 +189,7 @@ app.get('/api/cost-analytics', (req, res) => {
165
189
  const opts = {
166
190
  editor: req.query.editor || null,
167
191
  ...parseDateOpts(req.query),
192
+ hiddenFolders: getHiddenFolders(),
168
193
  };
169
194
  res.json(cache.getCostAnalytics(opts));
170
195
  } catch (err) {
@@ -179,6 +204,7 @@ app.get('/api/costs', (req, res) => {
179
204
  folder: req.query.folder || null,
180
205
  chatId: req.query.chatId || null,
181
206
  ...parseDateOpts(req.query),
207
+ hiddenFolders: getHiddenFolders(),
182
208
  };
183
209
  res.json(cache.getCostBreakdown(opts));
184
210
  } catch (err) {
@@ -266,6 +292,33 @@ app.get('/api/refetch', async (req, res) => {
266
292
  res.end();
267
293
  });
268
294
 
295
+ // ============================================================
296
+ // Config endpoints
297
+ // ============================================================
298
+
299
+ app.get('/api/config', (req, res) => {
300
+ res.json(readConfig());
301
+ });
302
+
303
+ app.put('/api/config', (req, res) => {
304
+ try {
305
+ const config = readConfig();
306
+ Object.assign(config, req.body);
307
+ writeConfig(config);
308
+ res.json(config);
309
+ } catch (err) {
310
+ res.status(500).json({ error: err.message });
311
+ }
312
+ });
313
+
314
+ app.get('/api/all-projects', (req, res) => {
315
+ try {
316
+ res.json(cache.getCachedProjects({ ...parseDateOpts(req.query), includeHidden: true }));
317
+ } catch (err) {
318
+ res.status(500).json({ error: err.message });
319
+ }
320
+ });
321
+
269
322
  // SPA fallback
270
323
  app.get('*', (req, res) => {
271
324
  res.sendFile(path.join(__dirname, 'public', 'index.html'));
package/ui/src/App.jsx CHANGED
@@ -1,6 +1,6 @@
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, DollarSign, 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, Settings as SettingsIcon } from 'lucide-react'
4
4
  import { fetchOverview, refetchAgents, fetchMode, fetchRelayConfig, getAuthToken, setOnAuthFailure } from './lib/api'
5
5
  import { useTheme } from './lib/theme'
6
6
  import AnimatedLogo from './components/AnimatedLogo'
@@ -14,6 +14,7 @@ import Projects from './pages/Projects'
14
14
  import ProjectDetail from './pages/ProjectDetail'
15
15
  import CostAnalysis from './pages/CostAnalysis'
16
16
  import SqlViewer from './pages/SqlViewer'
17
+ import Settings from './pages/Settings'
17
18
  import RelayDashboard from './pages/RelayDashboard'
18
19
  import RelayUserDetail from './pages/RelayUserDetail'
19
20
 
@@ -168,6 +169,14 @@ export default function App() {
168
169
  Connect
169
170
  </button>
170
171
  )}
172
+ <NavLink
173
+ to="/settings"
174
+ className="p-1 rounded transition hover:bg-[var(--c-card)]"
175
+ style={({ isActive }) => ({ color: isActive ? '#6366f1' : 'var(--c-text2)' })}
176
+ title="Settings"
177
+ >
178
+ <SettingsIcon size={13} />
179
+ </NavLink>
171
180
  <button
172
181
  onClick={toggle}
173
182
  className="p-1 rounded transition hover:bg-[var(--c-card)]"
@@ -206,6 +215,7 @@ export default function App() {
206
215
  <Route path="/analysis" element={<DeepAnalysis overview={overview} />} />
207
216
  <Route path="/compare" element={<Compare overview={overview} />} />
208
217
  <Route path="/sql" element={<SqlViewer />} />
218
+ <Route path="/settings" element={<Settings />} />
209
219
  </Routes>
210
220
  )}
211
221
  </main>
package/ui/src/lib/api.js CHANGED
@@ -147,6 +147,25 @@ export async function fetchCosts(params = {}) {
147
147
  return res.json();
148
148
  }
149
149
 
150
+ export async function fetchConfig() {
151
+ const res = await fetch(`${BASE}/api/config`);
152
+ return res.json();
153
+ }
154
+
155
+ export async function updateConfig(data) {
156
+ const res = await fetch(`${BASE}/api/config`, {
157
+ method: 'PUT',
158
+ headers: { 'Content-Type': 'application/json' },
159
+ body: JSON.stringify(data),
160
+ });
161
+ return res.json();
162
+ }
163
+
164
+ export async function fetchAllProjects() {
165
+ const res = await fetch(`${BASE}/api/all-projects`);
166
+ return res.json();
167
+ }
168
+
150
169
  export async function executeQuery(sql) {
151
170
  const res = await fetch(`${BASE}/api/query`, {
152
171
  method: 'POST',
@@ -9,6 +9,7 @@ import DateRangePicker from '../components/DateRangePicker'
9
9
  import { editorColor, editorLabel, formatNumber, formatCost, dateRangeToApiParams } from '../lib/constants'
10
10
  import EditorIcon from '../components/EditorIcon'
11
11
  import { fetchDailyActivity, fetchOverview as fetchOverviewApi, fetchDashboardStats, fetchShareImage, fetchChats, fetchCosts } from '../lib/api'
12
+ import ChatSidebar from '../components/ChatSidebar'
12
13
  import { useTheme } from '../lib/theme'
13
14
  import SectionTitle from '../components/SectionTitle'
14
15
 
@@ -33,6 +34,7 @@ export default function Dashboard({ overview }) {
33
34
  const [costs, setCosts] = useState(null)
34
35
  const [sharing, setSharing] = useState(false)
35
36
  const [largeContextChats, setLargeContextChats] = useState(null)
37
+ const [selectedChatId, setSelectedChatId] = useState(null)
36
38
  const txtColor = dark ? '#888' : '#555'
37
39
  const txtDim = dark ? '#555' : '#999'
38
40
  const gridColor = dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.06)'
@@ -542,7 +544,7 @@ export default function Dashboard({ overview }) {
542
544
  key={c.id}
543
545
  className="flex items-center gap-1.5 px-1.5 py-1 rounded-sm cursor-pointer transition hover:opacity-80"
544
546
  style={{ background: c.bubbleCount >= 500 ? 'rgba(239,68,68,0.06)' : 'rgba(245,158,11,0.06)' }}
545
- onClick={() => navigate(`/sessions/${c.id}`)}
547
+ onClick={() => setSelectedChatId(c.id)}
546
548
  >
547
549
  <EditorIcon source={c.source} size={10} />
548
550
  <span className="text-[10px] truncate flex-1" style={{ color: 'var(--c-text)' }}>{c.name || 'Untitled'}</span>
@@ -558,6 +560,7 @@ export default function Dashboard({ overview }) {
558
560
  </div>
559
561
  )}
560
562
  </div>
563
+ <ChatSidebar chatId={selectedChatId} onClose={() => setSelectedChatId(null)} />
561
564
  </div>
562
565
  )
563
566
  }
@@ -0,0 +1,142 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { Settings as SettingsIcon, EyeOff, Eye, FolderOpen, Search } from 'lucide-react'
3
+ import { fetchConfig, updateConfig, fetchAllProjects } from '../lib/api'
4
+ import { editorLabel, formatNumber, formatDate } from '../lib/constants'
5
+ import EditorIcon from '../components/EditorIcon'
6
+ import SectionTitle from '../components/SectionTitle'
7
+
8
+ export default function Settings() {
9
+ const [config, setConfig] = useState(null)
10
+ const [projects, setProjects] = useState([])
11
+ const [loading, setLoading] = useState(true)
12
+ const [saving, setSaving] = useState(false)
13
+ const [search, setSearch] = useState('')
14
+
15
+ useEffect(() => {
16
+ Promise.all([fetchConfig(), fetchAllProjects()]).then(([cfg, projs]) => {
17
+ setConfig(cfg)
18
+ setProjects(projs)
19
+ setLoading(false)
20
+ }).catch(() => setLoading(false))
21
+ }, [])
22
+
23
+ if (loading || !config) {
24
+ return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading settings...</div>
25
+ }
26
+
27
+ const hiddenProjects = config.hiddenProjects || []
28
+
29
+ const toggleProject = async (folder) => {
30
+ setSaving(true)
31
+ const isHidden = hiddenProjects.includes(folder)
32
+ const updated = isHidden
33
+ ? hiddenProjects.filter(f => f !== folder)
34
+ : [...hiddenProjects, folder]
35
+ const newConfig = await updateConfig({ hiddenProjects: updated })
36
+ setConfig(newConfig)
37
+ setSaving(false)
38
+ }
39
+
40
+ const filtered = projects.filter(p => {
41
+ if (!search) return true
42
+ const q = search.toLowerCase()
43
+ return p.folder.toLowerCase().includes(q) || p.name.toLowerCase().includes(q)
44
+ })
45
+
46
+ const sorted = [...filtered].sort((a, b) => a.name.localeCompare(b.name))
47
+
48
+ return (
49
+ <div className="fade-in space-y-4">
50
+ <div className="flex items-center gap-1.5 text-[13px] font-bold" style={{ color: 'var(--c-white)' }}>
51
+ <SettingsIcon size={14} style={{ color: '#6366f1' }} />
52
+ Settings
53
+ </div>
54
+
55
+ <div className="card overflow-hidden">
56
+ <div className="px-3 py-2 flex items-center justify-between" style={{ borderBottom: '1px solid var(--c-border)' }}>
57
+ <SectionTitle>
58
+ <FolderOpen size={11} className="inline mr-1" />
59
+ projects ({projects.length})
60
+ </SectionTitle>
61
+ <div className="flex items-center gap-2">
62
+ {hiddenProjects.length > 0 && (
63
+ <span className="text-[10px] px-1.5 py-0.5" style={{ background: 'rgba(239,68,68,0.08)', color: '#ef4444', border: '1px solid rgba(239,68,68,0.15)' }}>
64
+ {hiddenProjects.length} hidden
65
+ </span>
66
+ )}
67
+ <div className="relative">
68
+ <Search size={11} className="absolute left-2 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
69
+ <input
70
+ type="text"
71
+ value={search}
72
+ onChange={e => setSearch(e.target.value)}
73
+ placeholder="Filter projects..."
74
+ className="pl-6 pr-2 py-1 text-[11px] outline-none w-[180px]"
75
+ style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
76
+ />
77
+ </div>
78
+ </div>
79
+ </div>
80
+ <div className="text-[11px] px-3 py-1.5" style={{ color: 'var(--c-text3)', borderBottom: '1px solid var(--c-border)', background: 'var(--c-bg3)' }}>
81
+ Hidden projects are excluded from all dashboard stats, sessions, costs, and analytics.
82
+ </div>
83
+
84
+ {sorted.map(p => (
85
+ <ProjectRow key={p.folder} project={p} hidden={hiddenProjects.includes(p.folder)} onToggle={toggleProject} saving={saving} />
86
+ ))}
87
+
88
+ {sorted.length === 0 && (
89
+ <div className="text-center py-6 text-[12px]" style={{ color: 'var(--c-text3)' }}>no projects match filter</div>
90
+ )}
91
+ </div>
92
+ </div>
93
+ )
94
+ }
95
+
96
+ function ProjectRow({ project: p, hidden, onToggle, saving }) {
97
+ const editors = Object.entries(p.editors || {}).sort((a, b) => b[1] - a[1])
98
+
99
+ return (
100
+ <div
101
+ className="flex items-center gap-3 px-3 py-2 transition"
102
+ style={{
103
+ borderBottom: '1px solid var(--c-border)',
104
+ opacity: hidden ? 0.5 : 1,
105
+ }}
106
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg3)'}
107
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
108
+ >
109
+ <button
110
+ onClick={() => onToggle(p.folder)}
111
+ disabled={saving}
112
+ className="shrink-0 p-1 rounded transition hover:bg-[var(--c-bg)]"
113
+ style={{ color: hidden ? '#ef4444' : 'var(--c-text3)', border: '1px solid var(--c-border)' }}
114
+ title={hidden ? 'Show this project' : 'Hide this project'}
115
+ >
116
+ {hidden ? <EyeOff size={12} /> : <Eye size={12} />}
117
+ </button>
118
+ <div className="min-w-0 flex-1">
119
+ <div className="text-[12px] font-medium truncate" style={{ color: hidden ? 'var(--c-text3)' : 'var(--c-white)' }}>
120
+ {p.name}
121
+ </div>
122
+ <div className="text-[10px] truncate" style={{ color: 'var(--c-text3)' }} title={p.folder}>
123
+ {p.folder}
124
+ </div>
125
+ </div>
126
+ <div className="flex items-center gap-2 shrink-0">
127
+ {editors.slice(0, 3).map(([src, count]) => (
128
+ <span key={src} className="flex items-center gap-1 text-[10px]" style={{ color: 'var(--c-text3)' }}>
129
+ <EditorIcon source={src} size={10} />
130
+ {count}
131
+ </span>
132
+ ))}
133
+ </div>
134
+ <div className="text-[11px] font-mono shrink-0" style={{ color: 'var(--c-text2)' }}>
135
+ {formatNumber(p.totalSessions)}
136
+ </div>
137
+ <div className="text-[10px] shrink-0 w-[80px] text-right" style={{ color: 'var(--c-text3)' }}>
138
+ {formatDate(p.lastSeen)}
139
+ </div>
140
+ </div>
141
+ )
142
+ }