agentlytics 0.1.2 → 0.1.3

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,10 +1,10 @@
1
- import { useState, useEffect, useCallback } from 'react'
1
+ import { useState, useEffect, useMemo } from 'react'
2
2
  import { useNavigate } from 'react-router-dom'
3
- import { Users, Cpu, ArrowRight, Search, Merge } from 'lucide-react'
3
+ import { Users, Cpu, ArrowRight, Search, Merge, MessageSquare, Zap, FolderOpen, ChevronDown, ChevronRight, User } 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 KpiCard from '../components/KpiCard'
7
- import EditorDot from '../components/EditorDot'
7
+ import EditorIcon from '../components/EditorIcon'
8
8
  import SectionTitle from '../components/SectionTitle'
9
9
  import ChatSidebar from '../components/ChatSidebar'
10
10
  import LiveFeed from '../components/LiveFeed'
@@ -15,6 +15,240 @@ import { useTheme } from '../lib/theme'
15
15
  ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement)
16
16
 
17
17
  const MONO = 'JetBrains Mono, monospace'
18
+ const MODEL_COLORS = ['#6366f1', '#a78bfa', '#818cf8', '#c084fc', '#e879f9', '#f472b6', '#fb7185', '#f87171', '#fbbf24', '#34d399']
19
+ const USER_COLORS = ['#6366f1', '#8b5cf6', '#a855f7', '#c084fc', '#d8b4fe', '#e879f9', '#f472b6', '#fb7185']
20
+
21
+ function ProportionBar({ segments, height = 6 }) {
22
+ const total = segments.reduce((s, seg) => s + seg.value, 0)
23
+ if (total === 0) return null
24
+ return (
25
+ <div className="flex w-full rounded-full overflow-hidden" style={{ height }}>
26
+ {segments.filter(s => s.value > 0).map((seg, i) => (
27
+ <div
28
+ key={i}
29
+ title={`${seg.label}: ${formatNumber(seg.value)}`}
30
+ className="h-full transition-all"
31
+ style={{ width: `${(seg.value / total * 100).toFixed(1)}%`, background: seg.color }}
32
+ />
33
+ ))}
34
+ </div>
35
+ )
36
+ }
37
+
38
+ // Left sidebar: team members grouped by project (folder-tree view)
39
+ function TeamSidebar({ userList, userColorMap, onUserClick, selectedUser }) {
40
+ const [collapsed, setCollapsed] = useState(new Set())
41
+ const navigate = useNavigate()
42
+
43
+ // Build project → users mapping
44
+ const { projectGroups, ungrouped } = useMemo(() => {
45
+ const projMap = {}
46
+ const seen = new Set()
47
+ for (const u of userList) {
48
+ const projects = u.sharedProjects || []
49
+ if (projects.length === 0) {
50
+ seen.add(u.username)
51
+ continue
52
+ }
53
+ for (const p of projects) {
54
+ const name = typeof p === 'string' ? p : (p.name || p)
55
+ if (!projMap[name]) projMap[name] = []
56
+ projMap[name].push(u)
57
+ seen.add(u.username)
58
+ }
59
+ }
60
+ const ungrouped = userList.filter(u => !(u.sharedProjects?.length > 0))
61
+ const projectGroups = Object.entries(projMap).sort((a, b) => b[1].length - a[1].length)
62
+ return { projectGroups, ungrouped }
63
+ }, [userList])
64
+
65
+ const toggle = (key) => {
66
+ setCollapsed(prev => {
67
+ const next = new Set(prev)
68
+ next.has(key) ? next.delete(key) : next.add(key)
69
+ return next
70
+ })
71
+ }
72
+
73
+ const UserItem = ({ u }) => {
74
+ const idx = userList.indexOf(u)
75
+ const color = userColorMap[u.username] || '#6366f1'
76
+ const editorEntries = Object.entries(u.editors || {}).sort((a, b) => b[1] - a[1])
77
+ return (
78
+ <div
79
+ className="flex items-center gap-1.5 px-2 py-1.5 cursor-pointer transition rounded-sm"
80
+ style={{
81
+ background: selectedUser === u.username ? 'rgba(99,102,241,0.12)' : 'transparent',
82
+ }}
83
+ onMouseEnter={e => { if (selectedUser !== u.username) e.currentTarget.style.background = 'var(--c-bg3)' }}
84
+ onMouseLeave={e => { if (selectedUser !== u.username) e.currentTarget.style.background = 'transparent' }}
85
+ onClick={() => navigate(`/relay/user/${u.username}`)}
86
+ >
87
+ <div className="w-5 h-5 flex items-center justify-center text-[8px] font-bold rounded-sm flex-shrink-0" style={{ background: `${color}20`, color }}>
88
+ {u.username.charAt(0).toUpperCase()}
89
+ </div>
90
+ <div className="flex-1 min-w-0">
91
+ <div className="text-[10px] font-medium truncate" style={{ color: 'var(--c-white)' }}>{u.username}</div>
92
+ <div className="flex items-center gap-1 text-[8px]" style={{ color: 'var(--c-text3)' }}>
93
+ <span>{u.sessions}s</span>
94
+ <span>·</span>
95
+ <span>{formatNumber(u.totalMessages)}m</span>
96
+ </div>
97
+ {editorEntries.length > 0 && (
98
+ <div className="flex items-center gap-1 mt-0.5">
99
+ {editorEntries.slice(0, 4).map(([src]) => (
100
+ <EditorIcon key={src} source={src} size={9} />
101
+ ))}
102
+ </div>
103
+ )}
104
+ </div>
105
+ </div>
106
+ )
107
+ }
108
+
109
+ return (
110
+ <div className="flex flex-col h-full">
111
+ <div className="flex items-center gap-2 px-3 py-2.5 shrink-0" style={{ borderBottom: '1px solid var(--c-border)' }}>
112
+ <Users size={12} style={{ color: 'var(--c-accent)' }} />
113
+ <span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--c-text2)' }}>Team</span>
114
+ <span className="text-[9px] ml-auto" style={{ color: 'var(--c-text3)' }}>{userList.length}</span>
115
+ </div>
116
+
117
+ <div className="flex-1 overflow-y-auto scrollbar-thin py-1">
118
+ {/* Project groups */}
119
+ {projectGroups.map(([proj, users]) => {
120
+ const isCollapsed = collapsed.has(proj)
121
+ const projName = proj.split('/').pop()
122
+ return (
123
+ <div key={proj}>
124
+ <div
125
+ className="flex items-center gap-1.5 px-2 py-1.5 cursor-pointer transition"
126
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg3)'}
127
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
128
+ onClick={() => toggle(proj)}
129
+ >
130
+ {isCollapsed ? <ChevronRight size={10} style={{ color: 'var(--c-text3)' }} /> : <ChevronDown size={10} style={{ color: 'var(--c-text3)' }} />}
131
+ <FolderOpen size={10} style={{ color: '#818cf8' }} />
132
+ <span className="text-[10px] font-medium truncate flex-1" style={{ color: 'var(--c-text2)' }} title={proj}>{projName}</span>
133
+ <span className="text-[8px]" style={{ color: 'var(--c-text3)' }}>{users.length}</span>
134
+ </div>
135
+ {!isCollapsed && (
136
+ <div className="pl-3">
137
+ {users.map(u => <UserItem key={`${proj}-${u.username}`} u={u} />)}
138
+ </div>
139
+ )}
140
+ </div>
141
+ )
142
+ })}
143
+
144
+ {/* Ungrouped users */}
145
+ {ungrouped.length > 0 && (
146
+ <div>
147
+ {projectGroups.length > 0 && (
148
+ <div className="px-2 py-1.5 text-[9px] uppercase tracking-wider" style={{ color: 'var(--c-text3)' }}>
149
+ unassigned
150
+ </div>
151
+ )}
152
+ <div className={projectGroups.length > 0 ? 'pl-1' : ''}>
153
+ {ungrouped.map(u => <UserItem key={u.username} u={u} />)}
154
+ </div>
155
+ </div>
156
+ )}
157
+
158
+ {userList.length === 0 && (
159
+ <div className="text-[10px] py-6 text-center" style={{ color: 'var(--c-text3)' }}>
160
+ No team members yet
161
+ </div>
162
+ )}
163
+ </div>
164
+
165
+ {/* Merge section at bottom */}
166
+ {userList.length >= 2 && (
167
+ <MergeSection userList={userList} />
168
+ )}
169
+ </div>
170
+ )
171
+ }
172
+
173
+ function MergeSection({ userList }) {
174
+ const [mergeFrom, setMergeFrom] = useState('')
175
+ const [mergeTo, setMergeTo] = useState('')
176
+ const [merging, setMerging] = useState(false)
177
+ const [mergeResult, setMergeResult] = useState(null)
178
+ const [open, setOpen] = useState(false)
179
+
180
+ return (
181
+ <div className="shrink-0 px-2 py-2" style={{ borderTop: '1px solid var(--c-border)' }}>
182
+ <div
183
+ className="flex items-center gap-1.5 cursor-pointer text-[9px] uppercase tracking-wider"
184
+ style={{ color: 'var(--c-text3)' }}
185
+ onClick={() => setOpen(!open)}
186
+ >
187
+ {open ? <ChevronDown size={9} /> : <ChevronRight size={9} />}
188
+ <Merge size={9} />
189
+ <span>merge users</span>
190
+ </div>
191
+ {open && (
192
+ <div className="mt-2 space-y-1.5">
193
+ <select
194
+ value={mergeFrom}
195
+ onChange={e => setMergeFrom(e.target.value)}
196
+ className="w-full text-[10px] px-1.5 py-1 outline-none rounded-sm"
197
+ style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
198
+ >
199
+ <option value="">from...</option>
200
+ {userList.filter(u => u.username !== mergeTo).map(u => (
201
+ <option key={u.username} value={u.username}>{u.username}</option>
202
+ ))}
203
+ </select>
204
+ <select
205
+ value={mergeTo}
206
+ onChange={e => setMergeTo(e.target.value)}
207
+ className="w-full text-[10px] px-1.5 py-1 outline-none rounded-sm"
208
+ style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
209
+ >
210
+ <option value="">into...</option>
211
+ {userList.filter(u => u.username !== mergeFrom).map(u => (
212
+ <option key={u.username} value={u.username}>{u.username}</option>
213
+ ))}
214
+ </select>
215
+ <button
216
+ disabled={!mergeFrom || !mergeTo || merging}
217
+ onClick={async () => {
218
+ if (!confirm(`Merge "${mergeFrom}" → "${mergeTo}"? Cannot undo.`)) return
219
+ setMerging(true)
220
+ setMergeResult(null)
221
+ try {
222
+ const r = await mergeRelayUsers(mergeFrom, mergeTo)
223
+ setMergeResult(r)
224
+ setMergeFrom('')
225
+ setMergeTo('')
226
+ } catch (err) {
227
+ setMergeResult({ error: err.message })
228
+ }
229
+ setMerging(false)
230
+ }}
231
+ className="w-full text-[10px] px-2 py-1 font-medium transition rounded-sm"
232
+ style={{
233
+ background: mergeFrom && mergeTo ? 'rgba(239,68,68,0.15)' : 'var(--c-bg3)',
234
+ color: mergeFrom && mergeTo ? '#ef4444' : 'var(--c-text3)',
235
+ border: '1px solid var(--c-border)',
236
+ cursor: !mergeFrom || !mergeTo || merging ? 'not-allowed' : 'pointer',
237
+ opacity: !mergeFrom || !mergeTo || merging ? 0.5 : 1,
238
+ }}
239
+ >
240
+ {merging ? 'Merging...' : 'Merge'}
241
+ </button>
242
+ {mergeResult && (
243
+ <div className="text-[9px]" style={{ color: mergeResult.error ? '#ef4444' : '#22c55e' }}>
244
+ {mergeResult.error ? `Error: ${mergeResult.error}` : 'Merged!'}
245
+ </div>
246
+ )}
247
+ </div>
248
+ )}
249
+ </div>
250
+ )
251
+ }
18
252
 
19
253
  export default function RelayDashboard() {
20
254
  const navigate = useNavigate()
@@ -24,16 +258,12 @@ export default function RelayDashboard() {
24
258
  const [searching, setSearching] = useState(false)
25
259
  const [selectedChat, setSelectedChat] = useState(null)
26
260
  const [selectedUsername, setSelectedUsername] = useState(null)
27
- const [mergeFrom, setMergeFrom] = useState('')
28
- const [mergeTo, setMergeTo] = useState('')
29
- const [merging, setMerging] = useState(false)
30
- const [mergeResult, setMergeResult] = useState(null)
31
261
  const { dark } = useTheme()
32
262
 
33
- const txtColor = dark ? '#888' : '#555'
34
263
  const legendColor = dark ? '#888' : '#555'
35
264
  const gridColor = dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.06)'
36
265
  const txtDim = dark ? '#555' : '#999'
266
+ const txtColor = dark ? '#888' : '#555'
37
267
 
38
268
  useEffect(() => {
39
269
  fetchRelayTeamStats().then(setStats)
@@ -52,316 +282,292 @@ export default function RelayDashboard() {
52
282
  setSearching(false)
53
283
  }
54
284
 
55
- if (!stats) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading relay data...</div>
56
-
57
- const editorData = stats.editors || []
58
- const userList = stats.users || []
59
-
60
- const editorChartData = {
61
- labels: editorData.map(e => editorLabel(e.source)),
62
- datasets: [{
63
- data: editorData.map(e => e.count),
64
- backgroundColor: editorData.map(e => editorColor(e.source)),
65
- borderWidth: 0,
66
- }],
67
- }
285
+ const userList = stats?.users || []
68
286
 
69
- const userSessionData = {
70
- labels: userList.map(u => u.username),
71
- datasets: [{
72
- label: 'Sessions',
73
- data: userList.map(u => u.sessions),
74
- backgroundColor: userList.map((_, i) => {
75
- const colors = ['#6366f1', '#8b5cf6', '#a855f7', '#c084fc', '#d8b4fe', '#e879f9', '#f472b6', '#fb7185']
76
- return colors[i % colors.length]
77
- }),
78
- borderWidth: 0,
79
- }],
80
- }
287
+ // Stable color map for users (must be before early return to keep hooks order)
288
+ const userColorMap = useMemo(() => {
289
+ const map = {}
290
+ userList.forEach((u, i) => { map[u.username] = USER_COLORS[i % USER_COLORS.length] })
291
+ return map
292
+ }, [userList])
81
293
 
82
- const chartOpts = {
83
- responsive: true, maintainAspectRatio: false,
84
- plugins: {
85
- legend: { position: 'right', labels: { color: legendColor, font: { size: 10, family: MONO }, padding: 12, usePointStyle: true, pointStyle: 'circle' } },
86
- tooltip: { bodyFont: { family: MONO, size: 11 }, titleFont: { family: MONO, size: 11 } },
87
- },
88
- }
294
+ if (!stats) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading relay data...</div>
89
295
 
90
- const barOpts = {
91
- responsive: true, maintainAspectRatio: false, indexAxis: 'y',
92
- plugins: {
93
- legend: { display: false },
94
- tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
95
- },
96
- scales: {
97
- x: { grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 8, family: MONO } }, beginAtZero: true },
98
- y: { grid: { display: false }, ticks: { color: txtColor, font: { size: 9, family: MONO } } },
99
- },
100
- }
296
+ const editorData = stats.editors || []
297
+ const models = stats.topModels || []
298
+ const totalTok = (stats.totalInputTokens || 0) + (stats.totalOutputTokens || 0)
299
+ const msgsPerSession = stats.totalSessions > 0 ? (stats.totalMessages / stats.totalSessions).toFixed(1) : 0
300
+ const tokPerSession = stats.totalSessions > 0 ? Math.round(totalTok / stats.totalSessions) : 0
301
+ const maxUserSessions = userList.length > 0 ? Math.max(...userList.map(u => u.sessions)) : 1
101
302
 
102
303
  const handleFeedClick = (chatId, username) => {
103
304
  setSelectedChat(chatId)
104
305
  setSelectedUsername(username)
105
306
  }
106
307
 
308
+ const sidebarH = 'calc(100vh - 42px)'
309
+
107
310
  return (
108
- <div className="fade-in flex gap-4">
109
- {/* ── Main content ── */}
110
- <div className="flex-1 min-w-0 space-y-4">
311
+ <div className="fade-in flex" style={{ height: sidebarH }}>
312
+ {/* ── Left sidebar: Team tree ── */}
313
+ <div
314
+ className="hidden lg:flex flex-col w-[250px] shrink-0 sticky top-[42px] self-start"
315
+ style={{ height: sidebarH, borderRight: '1px solid var(--c-border)', background: 'var(--c-bg)' }}
316
+ >
317
+ <TeamSidebar userList={userList} userColorMap={userColorMap} selectedUser={selectedUsername} onUserClick={u => navigate(`/relay/user/${u}`)} />
318
+ </div>
319
+
320
+ {/* ── Center: scrollable content ── */}
321
+ <div className="flex-1 min-w-0 overflow-y-auto p-4 space-y-4">
111
322
  {/* KPIs */}
112
- <div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-2">
113
- <KpiCard label="Team Members" value={stats.totalUsers} />
114
- <KpiCard label="Total Sessions" value={formatNumber(stats.totalSessions)} />
115
- <KpiCard label="Active Users" value={stats.activeUsers} />
116
- <KpiCard label="Projects" value={stats.totalProjects} />
117
- <KpiCard label="Messages" value={formatNumber(stats.totalMessages)} />
118
- <KpiCard label="Input Tokens" value={formatNumber(stats.totalInputTokens)} />
119
- <KpiCard label="Output Tokens" value={formatNumber(stats.totalOutputTokens)} />
323
+ <div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(90px, 1fr))' }}>
324
+ <KpiCard label="team members" value={stats.totalUsers} />
325
+ <KpiCard label="sessions" value={formatNumber(stats.totalSessions)} sub={`${msgsPerSession} msgs/session`} />
326
+ <KpiCard label="active users" value={stats.activeUsers} />
327
+ <KpiCard label="projects" value={stats.totalProjects} />
328
+ <KpiCard label="messages" value={formatNumber(stats.totalMessages)} />
329
+ <KpiCard label="tokens" value={formatNumber(totalTok)} sub={`${formatNumber(tokPerSession)}/session`} />
330
+ </div>
331
+
332
+ {/* Token overview */}
333
+ {totalTok > 0 && (
334
+ <div className="card p-3">
335
+ <div className="flex items-center justify-between mb-2">
336
+ <SectionTitle>team token usage</SectionTitle>
337
+ <span className="text-[10px] font-bold" style={{ color: 'var(--c-white)' }}>{formatNumber(totalTok)} total</span>
338
+ </div>
339
+ <ProportionBar height={10} segments={[
340
+ { label: 'Input', value: stats.totalInputTokens, color: '#6366f1' },
341
+ { label: 'Output', value: stats.totalOutputTokens, color: '#a78bfa' },
342
+ ]} />
343
+ <div className="flex items-center gap-4 mt-1.5 text-[9px]">
344
+ <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: '#6366f1' }} /> input {formatNumber(stats.totalInputTokens)}</span>
345
+ <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: '#a78bfa' }} /> output {formatNumber(stats.totalOutputTokens)}</span>
346
+ </div>
347
+ {userList.length > 1 && (
348
+ <div className="mt-3 pt-2" style={{ borderTop: '1px solid var(--c-border)' }}>
349
+ <div className="text-[9px] mb-1.5" style={{ color: 'var(--c-text3)' }}>per-user contribution</div>
350
+ <ProportionBar height={8} segments={userList.map(u => ({
351
+ label: u.username,
352
+ value: u.totalInputTokens + u.totalOutputTokens,
353
+ color: userColorMap[u.username],
354
+ }))} />
355
+ <div className="flex flex-wrap gap-3 mt-1 text-[9px]">
356
+ {userList.map(u => (
357
+ <span key={u.username} className="flex items-center gap-1" style={{ color: 'var(--c-text3)' }}>
358
+ <span className="w-2 h-2 rounded-sm" style={{ background: userColorMap[u.username] }} />
359
+ {u.username}
360
+ </span>
361
+ ))}
362
+ </div>
363
+ </div>
364
+ )}
365
+ </div>
366
+ )}
367
+
368
+ {/* Charts: editors + models + sessions per user */}
369
+ <div className="grid grid-cols-1 xl:grid-cols-3 gap-3">
370
+ {editorData.length > 0 && (
371
+ <div className="card p-3">
372
+ <SectionTitle>editors</SectionTitle>
373
+ <div style={{ height: 160 }}>
374
+ <Doughnut
375
+ data={{
376
+ labels: editorData.map(e => editorLabel(e.source)),
377
+ datasets: [{ data: editorData.map(e => e.count), backgroundColor: editorData.map(e => editorColor(e.source)), borderWidth: 0 }],
378
+ }}
379
+ options={{
380
+ responsive: true, maintainAspectRatio: false, cutout: '60%',
381
+ plugins: {
382
+ legend: { position: 'right', labels: { color: legendColor, font: { size: 9, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
383
+ tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
384
+ },
385
+ }}
386
+ />
387
+ </div>
388
+ </div>
389
+ )}
390
+ {models.length > 0 && (
391
+ <div className="card p-3">
392
+ <SectionTitle>models</SectionTitle>
393
+ <div style={{ height: 160 }}>
394
+ <Doughnut
395
+ data={{
396
+ labels: models.slice(0, 10).map(m => m.name),
397
+ datasets: [{ data: models.slice(0, 10).map(m => m.count), backgroundColor: MODEL_COLORS, borderWidth: 0 }],
398
+ }}
399
+ options={{
400
+ responsive: true, maintainAspectRatio: false, cutout: '60%',
401
+ plugins: {
402
+ legend: { position: 'right', labels: { color: legendColor, font: { size: 8, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
403
+ tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
404
+ },
405
+ }}
406
+ />
407
+ </div>
408
+ </div>
409
+ )}
410
+ {userList.length > 0 && (
411
+ <div className="card p-3">
412
+ <SectionTitle>sessions per user</SectionTitle>
413
+ <div style={{ height: Math.max(120, userList.length * 28) }}>
414
+ <Bar
415
+ data={{
416
+ labels: userList.map(u => u.username),
417
+ datasets: [{
418
+ data: userList.map(u => u.sessions),
419
+ backgroundColor: userList.map(u => userColorMap[u.username]),
420
+ borderRadius: 2,
421
+ }],
422
+ }}
423
+ options={{
424
+ responsive: true, maintainAspectRatio: false, indexAxis: 'y',
425
+ plugins: { legend: { display: false }, tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } } },
426
+ scales: {
427
+ x: { grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 8, family: MONO } }, beginAtZero: true },
428
+ y: { grid: { display: false }, ticks: { color: txtColor, font: { size: 9, family: MONO } } },
429
+ },
430
+ }}
431
+ />
432
+ </div>
433
+ </div>
434
+ )}
120
435
  </div>
121
436
 
122
437
  {/* Search */}
123
438
  <div className="card p-3">
124
- <SectionTitle>Search across team</SectionTitle>
125
- <form onSubmit={handleSearch} className="flex gap-2">
439
+ <SectionTitle>search across team</SectionTitle>
440
+ <form onSubmit={handleSearch} className="flex gap-2 mt-1">
126
441
  <div className="relative flex-1">
127
- <Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
442
+ <Search size={11} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
128
443
  <input
129
444
  type="text"
130
445
  value={search}
131
446
  onChange={e => setSearch(e.target.value)}
132
447
  placeholder="Search messages, files, topics across all users..."
133
- className="w-full pl-7 pr-3 py-1.5 text-[11px] bg-transparent border outline-none"
134
- style={{ borderColor: 'var(--c-border)', color: 'var(--c-white)' }}
448
+ className="w-full pl-7 pr-3 py-1.5 text-[11px] outline-none rounded-sm"
449
+ style={{ background: 'var(--c-bg3)', color: 'var(--c-white)', border: '1px solid var(--c-border)' }}
135
450
  />
136
451
  </div>
137
452
  <button
138
453
  type="submit"
139
454
  disabled={searching}
140
- className="px-3 py-1.5 text-[10px] font-medium transition"
455
+ className="px-3 py-1.5 text-[10px] font-medium transition rounded-sm"
141
456
  style={{ background: 'var(--c-card)', border: '1px solid var(--c-border)', color: 'var(--c-white)' }}
142
457
  >
143
458
  {searching ? 'Searching...' : 'Search'}
144
459
  </button>
145
460
  </form>
146
461
  {searchResults && (
147
- <div className="mt-3 max-h-[300px] overflow-y-auto scrollbar-thin space-y-1">
462
+ <div className="mt-3 max-h-[300px] overflow-y-auto scrollbar-thin">
148
463
  {searchResults.length === 0 ? (
149
464
  <div className="text-[11px] py-2" style={{ color: 'var(--c-text3)' }}>No results found</div>
150
465
  ) : (
151
- searchResults.map((r, i) => (
152
- <div
153
- key={i}
154
- className="card px-3 py-2 cursor-pointer hover:border-[var(--c-card-hover)] transition"
155
- onClick={() => { setSelectedChat(r.chatId); setSelectedUsername(r.username) }}
156
- >
157
- <div className="flex items-center gap-2 mb-1">
158
- <span className="text-[10px] font-medium px-1.5 py-0.5" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>{r.username}</span>
159
- <EditorDot source={r.source} showLabel size={6} />
160
- <span className="text-[10px]" style={{ color: 'var(--c-text3)' }}>{r.chatName}</span>
161
- <span className="text-[9px] ml-auto" style={{ color: 'var(--c-text3)' }}>{r.role}</span>
162
- </div>
163
- <div className="text-[10px] line-clamp-2" style={{ color: 'var(--c-text)' }}>{r.content}</div>
164
- </div>
165
- ))
466
+ <table className="w-full text-[11px]">
467
+ <tbody>
468
+ {searchResults.map((r, i) => (
469
+ <tr
470
+ key={i}
471
+ className="cursor-pointer transition"
472
+ style={{ borderBottom: '1px solid var(--c-border)' }}
473
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg3)'}
474
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
475
+ onClick={() => { setSelectedChat(r.chatId); setSelectedUsername(r.username) }}
476
+ >
477
+ <td className="py-2 px-2 w-[80px]">
478
+ <span className="text-[9px] font-medium px-1.5 py-0.5" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>{r.username}</span>
479
+ </td>
480
+ <td className="py-2 px-2 w-[24px]"><EditorIcon source={r.source} size={11} /></td>
481
+ <td className="py-2 px-2">
482
+ <div className="text-[10px] truncate" style={{ color: 'var(--c-text3)' }}>{r.chatName}</div>
483
+ <div className="text-[10px] line-clamp-1 mt-0.5" style={{ color: 'var(--c-text)' }}>{r.content}</div>
484
+ </td>
485
+ <td className="py-2 px-2 text-[9px] whitespace-nowrap" style={{ color: 'var(--c-text3)' }}>{r.role}</td>
486
+ </tr>
487
+ ))}
488
+ </tbody>
489
+ </table>
166
490
  )}
167
491
  </div>
168
492
  )}
169
493
  </div>
170
494
 
171
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
172
- {/* Editor breakdown */}
173
- {editorData.length > 0 && (
174
- <div className="card p-3">
175
- <SectionTitle>Team Editor Usage</SectionTitle>
176
- <div className="h-[200px]">
177
- <Doughnut data={editorChartData} options={chartOpts} />
178
- </div>
179
- </div>
180
- )}
181
-
182
- {/* Sessions per user */}
183
- {userList.length > 0 && (
184
- <div className="card p-3">
185
- <SectionTitle>Sessions per User</SectionTitle>
186
- <div style={{ height: Math.max(120, userList.length * 32) }}>
187
- <Bar data={userSessionData} options={barOpts} />
188
- </div>
189
- </div>
190
- )}
191
- </div>
192
-
193
- {/* Top Models */}
194
- {stats.topModels && stats.topModels.length > 0 && (
195
- <div className="card p-3">
196
- <SectionTitle>Top Models (Team)</SectionTitle>
197
- <div className="flex flex-wrap gap-2">
198
- {stats.topModels.map(m => (
199
- <span key={m.name} className="inline-flex items-center gap-1.5 px-2 py-1 text-[10px]" style={{ background: 'var(--c-card)', border: '1px solid var(--c-border)', color: 'var(--c-text)' }}>
200
- <Cpu size={10} style={{ color: '#818cf8' }} />
201
- {m.name}
202
- <span style={{ color: 'var(--c-text3)' }}>×{m.count}</span>
203
- </span>
204
- ))}
205
- </div>
206
- </div>
207
- )}
208
-
209
- {/* User cards */}
210
- <SectionTitle>Team Members</SectionTitle>
211
- <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
212
- {userList.map(u => (
213
- <div
214
- key={u.username}
215
- className="card p-3 cursor-pointer hover:border-[var(--c-card-hover)] transition"
216
- onClick={() => navigate(`/relay/user/${u.username}`)}
217
- >
218
- <div className="flex items-center justify-between mb-2">
219
- <div className="flex items-center gap-2">
220
- <div className="w-7 h-7 flex items-center justify-center text-[11px] font-bold" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>
495
+ {/* Team member cards (for detail view) */}
496
+ <SectionTitle>team overview</SectionTitle>
497
+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
498
+ {userList.map((u) => {
499
+ const uTok = u.totalInputTokens + u.totalOutputTokens
500
+ const color = userColorMap[u.username]
501
+ const editorEntries = Object.entries(u.editors || {}).sort((a, b) => b[1] - a[1])
502
+ return (
503
+ <div
504
+ key={u.username}
505
+ className="card p-3 cursor-pointer transition hover:opacity-90"
506
+ onClick={() => navigate(`/relay/user/${u.username}`)}
507
+ >
508
+ <div className="flex items-center gap-2 mb-2">
509
+ <div className="w-7 h-7 flex items-center justify-center text-[11px] font-bold rounded-sm flex-shrink-0" style={{ background: `${color}20`, color }}>
221
510
  {u.username.charAt(0).toUpperCase()}
222
511
  </div>
223
- <div>
224
- <div className="text-[11px] font-medium" style={{ color: 'var(--c-white)' }}>{u.username}</div>
512
+ <div className="flex-1 min-w-0">
513
+ <div className="text-[11px] font-bold truncate" style={{ color: 'var(--c-white)' }}>{u.username}</div>
225
514
  <div className="text-[9px]" style={{ color: 'var(--c-text3)' }}>
226
- {u.lastActive ? `Active ${formatDate(u.lastActive)}` : 'No activity'}
515
+ {u.lastActive ? formatDate(u.lastActive) : 'No activity'}
227
516
  </div>
228
517
  </div>
518
+ <div className="flex items-center gap-0.5 flex-shrink-0">
519
+ {editorEntries.slice(0, 3).map(([src]) => (
520
+ <EditorIcon key={src} source={src} size={11} />
521
+ ))}
522
+ </div>
229
523
  </div>
230
- <ArrowRight size={12} style={{ color: 'var(--c-text3)' }} />
231
- </div>
232
-
233
- <div className="grid grid-cols-3 gap-2 mb-2">
234
- <div>
235
- <div className="text-[12px] font-bold" style={{ color: 'var(--c-white)' }}>{u.sessions}</div>
236
- <div className="text-[9px]" style={{ color: 'var(--c-text3)' }}>sessions</div>
237
- </div>
238
- <div>
239
- <div className="text-[12px] font-bold" style={{ color: 'var(--c-white)' }}>{formatNumber(u.totalMessages)}</div>
240
- <div className="text-[9px]" style={{ color: 'var(--c-text3)' }}>messages</div>
524
+ <div className="grid grid-cols-4 gap-1 text-center mb-2">
525
+ {[
526
+ [u.sessions, 'sessions'],
527
+ [formatNumber(u.totalMessages), 'messages'],
528
+ [u.projects, 'projects'],
529
+ [formatNumber(uTok), 'tokens'],
530
+ ].map(([v, l]) => (
531
+ <div key={l} className="p-1 rounded-sm" style={{ background: 'var(--c-code-bg)' }}>
532
+ <div className="text-[10px] font-bold" style={{ color: 'var(--c-white)' }}>{v}</div>
533
+ <div className="text-[7px]" style={{ color: 'var(--c-text3)' }}>{l}</div>
534
+ </div>
535
+ ))}
241
536
  </div>
242
- <div>
243
- <div className="text-[12px] font-bold" style={{ color: 'var(--c-white)' }}>{u.projects}</div>
244
- <div className="text-[9px]" style={{ color: 'var(--c-text3)' }}>projects</div>
537
+ {/* Activity bar */}
538
+ <div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--c-code-bg)' }}>
539
+ <div className="h-full rounded-full" style={{ width: `${(u.sessions / maxUserSessions * 100).toFixed(0)}%`, background: color }} />
245
540
  </div>
541
+ {u.topModels && u.topModels.length > 0 && (
542
+ <div className="flex flex-wrap gap-1 mt-1.5">
543
+ {u.topModels.slice(0, 2).map(m => (
544
+ <span key={m.name} className="text-[8px] px-1.5 py-0.5 rounded-sm" style={{ background: 'rgba(99,102,241,0.08)', color: '#818cf8' }}>{m.name}</span>
545
+ ))}
546
+ </div>
547
+ )}
246
548
  </div>
247
-
248
- {/* Editor dots */}
249
- <div className="flex flex-wrap gap-2">
250
- {Object.entries(u.editors).sort((a, b) => b[1] - a[1]).map(([src, count]) => (
251
- <span key={src} className="inline-flex items-center gap-1 text-[9px]" style={{ color: 'var(--c-text2)' }}>
252
- <EditorDot source={src} size={6} />
253
- {editorLabel(src)} <span style={{ color: 'var(--c-text3)' }}>{count}</span>
254
- </span>
255
- ))}
256
- </div>
257
-
258
- {/* Top models */}
259
- {u.topModels && u.topModels.length > 0 && (
260
- <div className="flex flex-wrap gap-1 mt-1.5">
261
- {u.topModels.slice(0, 3).map(m => (
262
- <span key={m.name} className="text-[8px] px-1.5 py-0.5" style={{ background: 'rgba(99,102,241,0.08)', color: '#818cf8' }}>
263
- {m.name}
264
- </span>
265
- ))}
266
- </div>
267
- )}
268
- </div>
269
- ))}
549
+ )
550
+ })}
270
551
  </div>
271
552
 
272
- {/* Merge users */}
273
- {userList.length >= 2 && (
274
- <div className="card p-3">
275
- <SectionTitle><Merge size={12} className="inline mr-1" />Merge Users</SectionTitle>
276
- <div className="flex items-end gap-2 flex-wrap">
277
- <div>
278
- <div className="text-[9px] mb-1" style={{ color: 'var(--c-text3)' }}>Merge from</div>
279
- <select
280
- value={mergeFrom}
281
- onChange={e => setMergeFrom(e.target.value)}
282
- className="text-[11px] px-2 py-1.5 outline-none"
283
- style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
284
- >
285
- <option value="">Select user...</option>
286
- {userList.filter(u => u.username !== mergeTo).map(u => (
287
- <option key={u.username} value={u.username}>{u.username} ({u.sessions} sessions)</option>
288
- ))}
289
- </select>
290
- </div>
291
- <div className="text-[11px] pb-1.5" style={{ color: 'var(--c-text3)' }}>→</div>
292
- <div>
293
- <div className="text-[9px] mb-1" style={{ color: 'var(--c-text3)' }}>Merge into</div>
294
- <select
295
- value={mergeTo}
296
- onChange={e => setMergeTo(e.target.value)}
297
- className="text-[11px] px-2 py-1.5 outline-none"
298
- style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
299
- >
300
- <option value="">Select user...</option>
301
- {userList.filter(u => u.username !== mergeFrom).map(u => (
302
- <option key={u.username} value={u.username}>{u.username} ({u.sessions} sessions)</option>
303
- ))}
304
- </select>
305
- </div>
306
- <button
307
- disabled={!mergeFrom || !mergeTo || merging}
308
- onClick={async () => {
309
- if (!confirm(`Merge all data from "${mergeFrom}" into "${mergeTo}"? This cannot be undone.`)) return
310
- setMerging(true)
311
- setMergeResult(null)
312
- try {
313
- const r = await mergeRelayUsers(mergeFrom, mergeTo)
314
- setMergeResult(r)
315
- setMergeFrom('')
316
- setMergeTo('')
317
- fetchRelayTeamStats().then(setStats)
318
- } catch (err) {
319
- setMergeResult({ error: err.message })
320
- }
321
- setMerging(false)
322
- }}
323
- className="text-[10px] px-3 py-1.5 font-medium transition"
324
- style={{
325
- background: mergeFrom && mergeTo ? 'rgba(239,68,68,0.15)' : 'var(--c-bg3)',
326
- color: mergeFrom && mergeTo ? '#ef4444' : 'var(--c-text3)',
327
- border: '1px solid var(--c-border)',
328
- cursor: !mergeFrom || !mergeTo || merging ? 'not-allowed' : 'pointer',
329
- opacity: !mergeFrom || !mergeTo || merging ? 0.5 : 1,
330
- }}
331
- >
332
- {merging ? 'Merging...' : 'Merge'}
333
- </button>
334
- </div>
335
- {mergeResult && (
336
- <div className="mt-2 text-[10px]" style={{ color: mergeResult.error ? '#ef4444' : '#22c55e' }}>
337
- {mergeResult.error
338
- ? `Error: ${mergeResult.error}`
339
- : `Merged ${mergeResult.moved?.chats || 0} chats, ${mergeResult.moved?.messages || 0} messages from "${mergeResult.merged?.from}" → "${mergeResult.merged?.to}". ${mergeResult.duplicatesSkipped || 0} duplicates skipped.`}
340
- </div>
341
- )}
342
- </div>
343
- )}
344
-
345
553
  {userList.length === 0 && (
346
554
  <div className="card p-8 text-center">
347
555
  <Users size={32} className="mx-auto mb-3" style={{ color: 'var(--c-text3)' }} />
348
556
  <div className="text-[12px] font-medium mb-1" style={{ color: 'var(--c-white)' }}>No team members yet</div>
349
- <div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>
350
- Share the join command with your team to start collecting data
351
- </div>
557
+ <div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>Share the join command with your team to start collecting data</div>
352
558
  </div>
353
559
  )}
354
560
  </div>
355
561
 
356
- {/* ── Live Feed (right column) ── */}
562
+ {/* ── Right sidebar: Live Feed ── */}
357
563
  <div
358
- className="hidden xl:block w-[280px] shrink-0 card sticky top-[42px] self-start"
359
- style={{ height: 'calc(100vh - 58px)', overflow: 'hidden' }}
564
+ className="hidden xl:flex flex-col w-[300px] shrink-0 sticky top-[42px] self-start"
565
+ style={{ height: sidebarH, borderLeft: '1px solid var(--c-border)', background: 'var(--c-bg)' }}
360
566
  >
361
567
  <LiveFeed onSessionClick={handleFeedClick} />
362
568
  </div>
363
569
 
364
- {/* Session sidebar — same component as default UI */}
570
+ {/* Session sidebar */}
365
571
  <ChatSidebar
366
572
  chatId={selectedChat}
367
573
  onClose={() => { setSelectedChat(null); setSelectedUsername(null) }}