agentlytics 0.1.1 → 0.1.2

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.
@@ -1,19 +1,21 @@
1
1
  import { useState, useEffect, useMemo } from 'react'
2
2
  import { useSearchParams, useNavigate } from 'react-router-dom'
3
- import { ArrowLeft, Search } from 'lucide-react'
3
+ import { ArrowLeft, Search, FolderOpen, Calendar, MessageSquare, Wrench, Cpu, Zap, AlertTriangle } from 'lucide-react'
4
4
  import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement } from 'chart.js'
5
5
  import { Doughnut, Bar } from 'react-chartjs-2'
6
6
  import { fetchProjects, fetchChats } from '../lib/api'
7
7
  import { editorColor, editorLabel, formatNumber, formatDate } from '../lib/constants'
8
8
  import { useTheme } from '../lib/theme'
9
9
  import KpiCard from '../components/KpiCard'
10
- import EditorDot from '../components/EditorDot'
10
+ import EditorIcon from '../components/EditorIcon'
11
+ import SectionTitle from '../components/SectionTitle'
11
12
  import ChatSidebar from '../components/ChatSidebar'
12
13
 
13
14
  ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement)
14
15
 
15
16
  const MONO = 'JetBrains Mono, monospace'
16
17
  const MODEL_COLORS = ['#6366f1', '#a78bfa', '#818cf8', '#c084fc', '#e879f9', '#f472b6', '#fb7185', '#f87171', '#fbbf24', '#34d399']
18
+ const TOOL_COLORS = ['#10b981', '#34d399', '#6ee7b7', '#a7f3d0', '#d1fae5', '#ecfdf5', '#b8f0d8', '#7ce0b8', '#4ade80', '#22c55e']
17
19
 
18
20
  export default function ProjectDetail() {
19
21
  const [searchParams] = useSearchParams()
@@ -29,6 +31,7 @@ export default function ProjectDetail() {
29
31
  const [loading, setLoading] = useState(true)
30
32
  const [chatSearch, setChatSearch] = useState('')
31
33
  const [selectedChatId, setSelectedChatId] = useState(null)
34
+ const [enabledEditors, setEnabledEditors] = useState(null)
32
35
 
33
36
  useEffect(() => {
34
37
  if (!folder) return
@@ -40,19 +43,34 @@ export default function ProjectDetail() {
40
43
  const match = projects.find(p => p.folder === folder)
41
44
  setProject(match || null)
42
45
  setChats(chatData.chats || [])
46
+ if (match) setEnabledEditors(new Set(Object.keys(match.editors)))
43
47
  setLoading(false)
44
48
  })
45
49
  }, [folder])
46
50
 
51
+ const editorFilteredChats = useMemo(() => {
52
+ if (!enabledEditors) return chats
53
+ return chats.filter(c => enabledEditors.has(c.source))
54
+ }, [chats, enabledEditors])
55
+
47
56
  const filteredChats = useMemo(() => {
48
- if (!chatSearch) return chats
57
+ if (!chatSearch) return editorFilteredChats
49
58
  const q = chatSearch.toLowerCase()
50
- return chats.filter(c =>
59
+ return editorFilteredChats.filter(c =>
51
60
  (c.name && c.name.toLowerCase().includes(q)) ||
52
61
  (c.topModel && c.topModel.toLowerCase().includes(q)) ||
53
62
  (c.source && c.source.toLowerCase().includes(q))
54
63
  )
55
- }, [chats, chatSearch])
64
+ }, [editorFilteredChats, chatSearch])
65
+
66
+ const toggleEditor = (editorId) => {
67
+ setEnabledEditors(prev => {
68
+ const next = new Set(prev)
69
+ if (next.has(editorId)) next.delete(editorId)
70
+ else next.add(editorId)
71
+ return next
72
+ })
73
+ }
56
74
 
57
75
  if (!folder) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text3)' }}>no project specified</div>
58
76
  if (loading) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading project...</div>
@@ -60,65 +78,120 @@ export default function ProjectDetail() {
60
78
 
61
79
  const editorEntries = Object.entries(project.editors).sort((a, b) => b[1] - a[1])
62
80
  const maxEditorCount = editorEntries.length > 0 ? editorEntries[0][1] : 1
81
+ const allEnabled = !enabledEditors || enabledEditors.size === editorEntries.length
82
+
83
+ // Derive stats from editor-filtered chats
84
+ const fSessionCount = editorFilteredChats.length
85
+ const fEditorCounts = {}
86
+ const fModelCounts = {}
87
+ for (const c of editorFilteredChats) {
88
+ fEditorCounts[c.source] = (fEditorCounts[c.source] || 0) + 1
89
+ if (c.topModel) fModelCounts[c.topModel] = (fModelCounts[c.topModel] || 0) + 1
90
+ }
91
+ const fTopModels = Object.entries(fModelCounts).sort((a, b) => b[1] - a[1]).slice(0, 10)
92
+ const fMaxEditorCount = Math.max(...Object.values(fEditorCounts), 1)
93
+
94
+ // Use project-level stats when all editors enabled, otherwise show filtered session count
95
+ const totalTok = project.totalInputTokens + project.totalOutputTokens
96
+ const outputRatio = project.totalInputTokens > 0 ? (project.totalOutputTokens / project.totalInputTokens).toFixed(1) : '0'
97
+ const displaySessions = allEnabled ? project.totalSessions : fSessionCount
98
+ const avgMsgs = allEnabled && project.totalSessions > 0 ? (project.totalMessages / project.totalSessions).toFixed(1) : 0
63
99
 
64
100
  return (
65
- <div className="fade-in space-y-5">
66
- {/* Back + title */}
67
- <div className="flex items-center gap-3">
68
- <button
69
- onClick={() => navigate('/projects')}
70
- className="flex items-center gap-1.5 text-xs transition"
71
- style={{ color: 'var(--c-text2)' }}
72
- >
73
- <ArrowLeft size={14} /> Projects
74
- </button>
75
- <div className="flex-1 min-w-0">
76
- <h1 className="text-base font-semibold truncate" style={{ color: 'var(--c-white)' }}>{project.name}</h1>
77
- <div className="text-[10px] truncate" style={{ color: 'var(--c-text3)' }}>{project.folder}</div>
101
+ <div className="fade-in space-y-4">
102
+ {/* Header card */}
103
+ <div className="card p-4">
104
+ <div className="flex items-start gap-3">
105
+ <button
106
+ onClick={() => navigate('/projects')}
107
+ className="flex items-center gap-1 text-[11px] transition mt-0.5 flex-shrink-0"
108
+ style={{ color: 'var(--c-text3)' }}
109
+ >
110
+ <ArrowLeft size={12} />
111
+ </button>
112
+ <FolderOpen size={18} className="flex-shrink-0 mt-0.5" style={{ color: 'var(--c-accent)' }} />
113
+ <div className="flex-1 min-w-0">
114
+ <h1 className="text-sm font-bold truncate" style={{ color: 'var(--c-white)' }}>{project.name}</h1>
115
+ <div className="text-[10px] truncate" style={{ color: 'var(--c-text3)' }}>{project.folder}</div>
116
+ </div>
117
+ <div className="flex items-center gap-1.5 flex-shrink-0">
118
+ {editorEntries.map(([e]) => (
119
+ <EditorIcon key={e} source={e} size={14} />
120
+ ))}
121
+ </div>
122
+ </div>
123
+ <div className="flex items-center gap-4 mt-3 pt-3 text-[10px]" style={{ borderTop: '1px solid var(--c-border)' }}>
124
+ <div className="flex items-center gap-1" style={{ color: 'var(--c-text3)' }}>
125
+ <Calendar size={9} />
126
+ <span>{formatDate(project.firstSeen)}</span>
127
+ <span>→</span>
128
+ <span>{formatDate(project.lastSeen)}</span>
129
+ </div>
130
+ <div className="flex items-center gap-1.5 ml-auto">
131
+ {editorEntries.map(([e, c]) => (
132
+ <span key={e} className="inline-flex items-center gap-0.5">
133
+ <span className="w-1.5 h-1.5 rounded-full" style={{ background: editorColor(e) }} />
134
+ <span style={{ color: 'var(--c-text3)' }}>{editorLabel(e)}</span>
135
+ <span className="font-bold" style={{ color: 'var(--c-text2)' }}>{c}</span>
136
+ </span>
137
+ ))}
138
+ </div>
78
139
  </div>
79
140
  </div>
80
141
 
81
142
  {/* KPIs */}
82
- <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
83
- <KpiCard label="sessions" value={project.totalSessions} />
84
- <KpiCard label="messages" value={formatNumber(project.totalMessages)} />
85
- <KpiCard label="tool calls" value={formatNumber(project.totalToolCalls)} />
86
- <KpiCard label="input tokens" value={formatNumber(project.totalInputTokens)} />
87
- <KpiCard label="output tokens" value={formatNumber(project.totalOutputTokens)} />
88
- <KpiCard label="active since" value={formatDate(project.firstSeen)} />
143
+ <div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(90px, 1fr))' }}>
144
+ <KpiCard label="sessions" value={displaySessions} sub={!allEnabled ? 'filtered' : ''} />
145
+ <KpiCard label="messages" value={formatNumber(project.totalMessages)} sub={`${avgMsgs} avg/session`} />
146
+ <KpiCard label="tool calls" value={formatNumber(project.totalToolCalls)} sub={<span className="flex items-center gap-0.5"><Wrench size={8} /> invocations</span>} />
147
+ <KpiCard label="tokens" value={formatNumber(totalTok)} sub={`${outputRatio}× out/in`} />
148
+ {project.totalCacheRead > 0 && (
149
+ <KpiCard label="cache read" value={formatNumber(project.totalCacheRead)} sub={`write: ${formatNumber(project.totalCacheWrite)}`} />
150
+ )}
151
+ <KpiCard label="you wrote" value={formatNumber(project.totalUserChars)} sub={`AI: ${formatNumber(project.totalAssistantChars)}`} />
89
152
  </div>
90
153
 
91
154
  {/* Charts row */}
92
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
93
- {/* Editors */}
94
- <div className="card p-4">
95
- <h3 className="text-[10px] uppercase tracking-wider mb-3" style={{ color: 'var(--c-text2)' }}>editors</h3>
96
- <div className="space-y-2">
97
- {editorEntries.map(([e, c]) => (
98
- <div key={e} className="flex items-center gap-2">
99
- <span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: editorColor(e) }} />
100
- <span className="text-xs flex-1 truncate" style={{ color: 'var(--c-text2)' }}>{editorLabel(e)}</span>
101
- <div className="w-20 h-3 overflow-hidden" style={{ background: 'var(--c-code-bg)' }}>
102
- <div className="h-full" style={{ width: `${(c / maxEditorCount * 100).toFixed(0)}%`, background: editorColor(e) + '60' }} />
155
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
156
+ {/* Editors bar with checkboxes */}
157
+ <div className="card p-3">
158
+ <SectionTitle>editors</SectionTitle>
159
+ <div className="space-y-1.5 mt-1">
160
+ {editorEntries.map(([e, c]) => {
161
+ const checked = enabledEditors ? enabledEditors.has(e) : true
162
+ const count = fEditorCounts[e] || 0
163
+ return (
164
+ <div key={e} className="flex items-center gap-2">
165
+ <input
166
+ type="checkbox"
167
+ checked={checked}
168
+ onChange={() => toggleEditor(e)}
169
+ className="accent-[var(--c-accent)] w-3 h-3 flex-shrink-0 cursor-pointer"
170
+ />
171
+ <EditorIcon source={e} size={11} />
172
+ <span className="text-[10px] truncate flex-1 cursor-pointer select-none" onClick={() => toggleEditor(e)} style={{ color: checked ? 'var(--c-text2)' : 'var(--c-text3)', opacity: checked ? 1 : 0.4 }}>{editorLabel(e)}</span>
173
+ <div className="w-16 h-3 rounded-sm overflow-hidden" style={{ background: 'var(--c-code-bg)' }}>
174
+ <div className="h-full rounded-sm transition-all" style={{ width: `${(count / fMaxEditorCount * 100).toFixed(0)}%`, background: checked ? editorColor(e) : 'var(--c-text3)', opacity: checked ? 1 : 0.2 }} />
175
+ </div>
176
+ <span className="text-[10px] w-6 text-right font-bold" style={{ color: checked ? 'var(--c-white)' : 'var(--c-text3)', opacity: checked ? 1 : 0.4 }}>{count}</span>
103
177
  </div>
104
- <span className="text-[10px] w-6 text-right" style={{ color: 'var(--c-text3)' }}>{c}</span>
105
- </div>
106
- ))}
178
+ )
179
+ })}
107
180
  </div>
108
181
  </div>
109
182
 
110
- {/* Models chart */}
111
- <div className="card p-4">
112
- <h3 className="text-[10px] uppercase tracking-wider mb-3" style={{ color: 'var(--c-text2)' }}>models</h3>
113
- {project.topModels.length > 0 ? (
114
- <div style={{ height: 180 }}>
183
+ {/* Models doughnut */}
184
+ <div className="card p-3">
185
+ <SectionTitle>models</SectionTitle>
186
+ {fTopModels.length > 0 ? (
187
+ <div style={{ height: 160 }}>
115
188
  <Doughnut
116
189
  data={{
117
- labels: project.topModels.map(m => m.name),
118
- datasets: [{ data: project.topModels.map(m => m.count), backgroundColor: MODEL_COLORS, borderWidth: 0 }],
190
+ labels: fTopModels.map(m => m[0]),
191
+ datasets: [{ data: fTopModels.map(m => m[1]), backgroundColor: MODEL_COLORS, borderWidth: 0 }],
119
192
  }}
120
193
  options={{
121
- responsive: true, maintainAspectRatio: false, cutout: '55%',
194
+ responsive: true, maintainAspectRatio: false, cutout: '60%',
122
195
  plugins: {
123
196
  legend: { position: 'right', labels: { color: txtColor, font: { size: 8, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
124
197
  tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
@@ -129,17 +202,17 @@ export default function ProjectDetail() {
129
202
  ) : <div className="text-[10px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no model data</div>}
130
203
  </div>
131
204
 
132
- {/* Top tools */}
133
- <div className="card p-4">
134
- <h3 className="text-[10px] uppercase tracking-wider mb-3" style={{ color: 'var(--c-text2)' }}>top tools</h3>
205
+ {/* Top tools horizontal bar */}
206
+ <div className="card p-3">
207
+ <SectionTitle>top tools <span style={{ color: 'var(--c-text3)' }}>({formatNumber(project.totalToolCalls)})</span></SectionTitle>
135
208
  {project.topTools.length > 0 ? (
136
- <div style={{ height: 180 }}>
209
+ <div style={{ height: 160 }}>
137
210
  <Bar
138
211
  data={{
139
212
  labels: project.topTools.map(t => t.name),
140
213
  datasets: [{
141
214
  data: project.topTools.map(t => t.count),
142
- backgroundColor: 'rgba(99,102,241,0.4)',
215
+ backgroundColor: TOOL_COLORS.slice(0, project.topTools.length),
143
216
  borderRadius: 2,
144
217
  }],
145
218
  }}
@@ -157,18 +230,10 @@ export default function ProjectDetail() {
157
230
  </div>
158
231
  </div>
159
232
 
160
- {/* Token breakdown */}
161
- {(project.totalCacheRead > 0 || project.totalCacheWrite > 0) && (
162
- <div className="flex gap-4 text-[10px]" style={{ color: 'var(--c-text3)' }}>
163
- <span>cache read: {formatNumber(project.totalCacheRead)}</span>
164
- <span>cache write: {formatNumber(project.totalCacheWrite)}</span>
165
- </div>
166
- )}
167
-
168
- {/* Sessions list */}
233
+ {/* Sessions */}
169
234
  <div>
170
- <div className="flex items-center gap-3 mb-3">
171
- <h3 className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--c-text2)' }}>sessions ({chats.length})</h3>
235
+ <div className="flex items-center gap-3 mb-2">
236
+ <SectionTitle>sessions <span style={{ color: 'var(--c-text3)' }}>({filteredChats.length}{!allEnabled ? ` of ${chats.length}` : ''})</span></SectionTitle>
172
237
  <div className="relative max-w-xs flex-1">
173
238
  <Search size={11} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
174
239
  <input
@@ -176,21 +241,22 @@ export default function ProjectDetail() {
176
241
  placeholder="filter sessions..."
177
242
  value={chatSearch}
178
243
  onChange={e => setChatSearch(e.target.value)}
179
- className="w-full pl-7 pr-3 py-1 text-[11px] outline-none"
244
+ className="w-full pl-7 pr-3 py-1 text-[11px] outline-none rounded-sm"
180
245
  style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
181
246
  />
182
247
  </div>
183
248
  </div>
184
249
 
185
250
  <div className="card overflow-hidden">
186
- <table className="w-full text-sm">
251
+ <table className="w-full text-[11px]">
187
252
  <thead>
188
- <tr className="text-[10px] uppercase tracking-wider" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text3)' }}>
189
- <th className="text-left py-2.5 px-4 font-medium">editor</th>
190
- <th className="text-left py-2.5 px-4 font-medium">name</th>
191
- <th className="text-left py-2.5 px-4 font-medium">mode</th>
192
- <th className="text-left py-2.5 px-4 font-medium">model</th>
193
- <th className="text-left py-2.5 px-4 font-medium">updated</th>
253
+ <tr className="text-[9px] uppercase tracking-wider" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text3)' }}>
254
+ <th className="text-left py-2 px-3 font-medium">editor</th>
255
+ <th className="text-left py-2 px-3 font-medium">name</th>
256
+ <th className="text-left py-2 px-3 font-medium">mode</th>
257
+ <th className="text-left py-2 px-3 font-medium">model</th>
258
+ <th className="text-left py-2 px-3 font-medium">context</th>
259
+ <th className="text-left py-2 px-3 font-medium">updated</th>
194
260
  </tr>
195
261
  </thead>
196
262
  <tbody>
@@ -203,22 +269,32 @@ export default function ProjectDetail() {
203
269
  onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
204
270
  onClick={() => setSelectedChatId(c.id)}
205
271
  >
206
- <td className="py-2.5 px-4">
207
- <EditorDot source={c.source} showLabel size={7} />
208
- </td>
209
- <td className="py-2.5 px-4 font-medium truncate max-w-[300px]" style={{ color: 'var(--c-white)' }}>
210
- {c.name || <span style={{ color: 'var(--c-text3)' }}>(untitled)</span>}
211
- {c.encrypted && <span className="ml-2 text-[10px] text-yellow-500/60">locked</span>}
212
- </td>
213
- <td className="py-2.5 px-4">
214
- <span className="text-xs" style={{ color: 'var(--c-text2)' }}>{c.mode}</span>
272
+ <td className="py-2 px-3">
273
+ <span className="inline-flex items-center gap-1.5">
274
+ <EditorIcon source={c.source} size={12} />
275
+ <span style={{ color: 'var(--c-text2)' }}>{editorLabel(c.source)}</span>
276
+ </span>
215
277
  </td>
216
- <td className="py-2.5 px-4 text-xs truncate max-w-[180px] font-mono" style={{ color: 'var(--c-text2)' }} title={c.topModel || ''}>
217
- {c.topModel || ''}
278
+ <td className="py-2 px-3 font-medium truncate max-w-[280px]" style={{ color: 'var(--c-white)' }}>
279
+ {c.name || <span style={{ color: 'var(--c-text3)' }}>Untitled</span>}
280
+ {c.encrypted && <span className="ml-1.5 text-[9px] text-yellow-500/60">locked</span>}
218
281
  </td>
219
- <td className="py-2.5 px-4 text-xs whitespace-nowrap" style={{ color: 'var(--c-text2)' }}>
220
- {formatDate(c.lastUpdatedAt || c.createdAt)}
282
+ <td className="py-2 px-3" style={{ color: 'var(--c-text2)' }}>{c.mode || ''}</td>
283
+ <td className="py-2 px-3 font-mono truncate max-w-[150px]" style={{ color: 'var(--c-text2)' }} title={c.topModel || ''}>{c.topModel || ''}</td>
284
+ <td className="py-2 px-3">
285
+ {c.bubbleCount >= 500 ? (
286
+ <span className="inline-flex items-center gap-0.5 font-bold" style={{ color: '#ef4444' }}>
287
+ <AlertTriangle size={9} />{c.bubbleCount} msgs
288
+ </span>
289
+ ) : c.bubbleCount >= 100 ? (
290
+ <span className="inline-flex items-center gap-0.5 font-bold" style={{ color: '#f59e0b' }}>
291
+ <AlertTriangle size={9} />{c.bubbleCount} msgs
292
+ </span>
293
+ ) : (
294
+ <span style={{ color: 'var(--c-text3)' }}>{c.bubbleCount || 0} msgs</span>
295
+ )}
221
296
  </td>
297
+ <td className="py-2 px-3 whitespace-nowrap" style={{ color: 'var(--c-text3)' }}>{formatDate(c.lastUpdatedAt || c.createdAt)}</td>
222
298
  </tr>
223
299
  ))}
224
300
  </tbody>