agentlytics 0.1.6 → 0.1.8

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
@@ -7,7 +7,7 @@ const { calculateCost, getModelPricing, normalizeModelName } = require('./pricin
7
7
 
8
8
  const CACHE_DIR = path.join(os.homedir(), '.agentlytics');
9
9
  const CACHE_DB = path.join(CACHE_DIR, 'cache.db');
10
- const SCHEMA_VERSION = 4; // bump this when schema changes to auto-revalidate
10
+ const SCHEMA_VERSION = 5; // bump this when schema changes to auto-revalidate
11
11
 
12
12
  let db = null;
13
13
 
@@ -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); }
@@ -499,7 +516,26 @@ function getCachedChat(id) {
499
516
  if (!chat) return null;
500
517
 
501
518
  const stats = db.prepare('SELECT * FROM chat_stats WHERE chat_id = ?').get(chat.id);
502
- const messages = db.prepare('SELECT role, content, model, input_tokens, output_tokens FROM messages WHERE chat_id = ? ORDER BY seq').all(chat.id);
519
+ let messages = db.prepare('SELECT role, content, model, input_tokens, output_tokens FROM messages WHERE chat_id = ? ORDER BY seq').all(chat.id);
520
+
521
+ // If no cached messages, try fetching live from the editor
522
+ if (messages.length === 0 && !chat.encrypted) {
523
+ try {
524
+ const meta = JSON.parse(chat._meta || '{}');
525
+ const reconstructed = {
526
+ composerId: chat.id, source: chat.source, name: chat.name, mode: chat.mode,
527
+ folder: chat.folder, createdAt: chat.created_at, lastUpdatedAt: chat.last_updated_at,
528
+ encrypted: !!chat.encrypted, bubbleCount: chat.bubble_count,
529
+ ...meta,
530
+ };
531
+ const liveMessages = getMessages(reconstructed);
532
+ if (liveMessages && liveMessages.length > 0) {
533
+ // Store for next time
534
+ try { analyzeAndStore(reconstructed); } catch {}
535
+ messages = db.prepare('SELECT role, content, model, input_tokens, output_tokens FROM messages WHERE chat_id = ? ORDER BY seq').all(chat.id);
536
+ }
537
+ } catch {}
538
+ }
503
539
 
504
540
  let parsedStats = null;
505
541
  if (stats) {
@@ -542,6 +578,10 @@ function getCachedProjects(opts = {}) {
542
578
  // Build date filter
543
579
  let dateFilter = '';
544
580
  const dateParams = [];
581
+ if (!opts.includeHidden) {
582
+ const hf = hiddenFolderFilter(opts);
583
+ if (hf.sql) { dateFilter += hf.sql; dateParams.push(...hf.params); }
584
+ }
545
585
  if (opts.dateFrom) { dateFilter += ' AND COALESCE(last_updated_at, created_at) >= ?'; dateParams.push(opts.dateFrom); }
546
586
  if (opts.dateTo) { dateFilter += ' AND COALESCE(last_updated_at, created_at) <= ?'; dateParams.push(opts.dateTo); }
547
587
 
@@ -745,6 +785,8 @@ function getCachedDashboardStats(opts = {}) {
745
785
  // Build conditions dynamically to support editor + date range filters
746
786
  const conditions = [];
747
787
  const params = [];
788
+ const hf = hiddenFolderFilter(opts);
789
+ if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
748
790
  if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
749
791
  if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
750
792
  if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
@@ -1103,6 +1145,8 @@ function estimateCosts(whereClause = '', params = []) {
1103
1145
  function getCostBreakdown(opts = {}) {
1104
1146
  let whereClause = '';
1105
1147
  const params = [];
1148
+ const hf = hiddenFolderFilter(opts, 'c.folder');
1149
+ if (hf.sql) { whereClause += hf.sql; params.push(...hf.params); }
1106
1150
  if (opts.editor) { whereClause += ' AND c.source LIKE ?'; params.push(`%${opts.editor}%`); }
1107
1151
  if (opts.folder) { whereClause += ' AND c.folder = ?'; params.push(opts.folder); }
1108
1152
  if (opts.dateFrom) { whereClause += ' AND COALESCE(c.last_updated_at, c.created_at) >= ?'; params.push(opts.dateFrom); }
@@ -1114,6 +1158,8 @@ function getCostBreakdown(opts = {}) {
1114
1158
  function getCostAnalytics(opts = {}) {
1115
1159
  const conditions = [];
1116
1160
  const params = [];
1161
+ const hf = hiddenFolderFilter(opts, 'c.folder');
1162
+ if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
1117
1163
  if (opts.editor) { conditions.push('c.source LIKE ?'); params.push(`%${opts.editor}%`); }
1118
1164
  if (opts.dateFrom) { conditions.push('COALESCE(c.last_updated_at, c.created_at) >= ?'); params.push(opts.dateFrom); }
1119
1165
  if (opts.dateTo) { conditions.push('COALESCE(c.last_updated_at, c.created_at) <= ?'); params.push(opts.dateTo); }
package/editors/cursor.js CHANGED
@@ -182,6 +182,7 @@ function getComposerBubbles(globalDb, composerId) {
182
182
  function bubblesToMessages(bubbles) {
183
183
  const messages = [];
184
184
  for (const b of bubbles) {
185
+ if (!b) continue;
185
186
  const type = b.type; // 1=user, 2=assistant
186
187
  if (type === 1) {
187
188
  const text = b.text || '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlytics",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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
+ }