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.
- package/package.json +1 -1
- package/ui/src/App.jsx +5 -5
- package/ui/src/pages/RelayDashboard.jsx +460 -254
- package/ui/src/pages/RelayUserDetail.jsx +373 -109
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { useState, useEffect,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
109
|
-
{/* ──
|
|
110
|
-
<div
|
|
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
|
|
113
|
-
<KpiCard label="
|
|
114
|
-
<KpiCard label="
|
|
115
|
-
<KpiCard label="
|
|
116
|
-
<KpiCard label="
|
|
117
|
-
<KpiCard label="
|
|
118
|
-
<KpiCard label="
|
|
119
|
-
|
|
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>
|
|
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={
|
|
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]
|
|
134
|
-
style={{
|
|
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
|
|
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
|
-
|
|
152
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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-
|
|
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 ?
|
|
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
|
-
<
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
<div className="
|
|
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
|
-
|
|
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
|
|
562
|
+
{/* ── Right sidebar: Live Feed ── */}
|
|
357
563
|
<div
|
|
358
|
-
className="hidden xl:
|
|
359
|
-
style={{ height: '
|
|
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
|
|
570
|
+
{/* Session sidebar */}
|
|
365
571
|
<ChatSidebar
|
|
366
572
|
chatId={selectedChat}
|
|
367
573
|
onClose={() => { setSelectedChat(null); setSelectedUsername(null) }}
|