agentlytics 0.0.10 → 0.1.0

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/ui/src/App.jsx CHANGED
@@ -1,8 +1,9 @@
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, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database } from 'lucide-react'
4
- import { fetchOverview, refetchAgents } from './lib/api'
3
+ import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Radio, Plug, Copy, Check } from 'lucide-react'
4
+ import { fetchOverview, refetchAgents, fetchMode, fetchRelayConfig, getAuthToken, setOnAuthFailure } from './lib/api'
5
5
  import { useTheme } from './lib/theme'
6
+ import LoginScreen from './components/LoginScreen'
6
7
  import Dashboard from './pages/Dashboard'
7
8
  import Sessions from './pages/Sessions'
8
9
  import DeepAnalysis from './pages/DeepAnalysis'
@@ -11,13 +12,38 @@ import ChatDetail from './pages/ChatDetail'
11
12
  import Projects from './pages/Projects'
12
13
  import ProjectDetail from './pages/ProjectDetail'
13
14
  import SqlViewer from './pages/SqlViewer'
15
+ import RelayDashboard from './pages/RelayDashboard'
16
+ import RelayUserDetail from './pages/RelayUserDetail'
14
17
 
15
18
  export default function App() {
16
19
  const [overview, setOverview] = useState(null)
17
20
  const [refetchState, setRefetchState] = useState(null) // null | { scanned, total }
18
21
  const [live, setLive] = useState(false)
22
+ const [mode, setMode] = useState(null) // 'local' | 'relay'
23
+ const [needsAuth, setNeedsAuth] = useState(false)
24
+ const [authed, setAuthed] = useState(!!getAuthToken())
19
25
  const liveRef = useRef(null)
20
26
  const { dark, toggle } = useTheme()
27
+ const [mcpOpen, setMcpOpen] = useState(false)
28
+ const [mcpCopied, setMcpCopied] = useState(false)
29
+ const [relayPassword, setRelayPassword] = useState('')
30
+
31
+ useEffect(() => {
32
+ setOnAuthFailure(() => setAuthed(false))
33
+ }, [])
34
+
35
+ useEffect(() => {
36
+ fetchMode().then(data => {
37
+ setMode(data.mode || 'local')
38
+ setNeedsAuth(!!data.auth)
39
+ })
40
+ }, [])
41
+
42
+ useEffect(() => {
43
+ if (mode === 'relay' && authed) {
44
+ fetchRelayConfig().then(c => setRelayPassword(c.relayPassword || '')).catch(() => {})
45
+ }
46
+ }, [mode, authed])
21
47
 
22
48
  const refreshOverview = useCallback(() => {
23
49
  fetchOverview().then(setOverview)
@@ -50,7 +76,12 @@ export default function App() {
50
76
  setRefetchState(null)
51
77
  }
52
78
 
53
- const nav = [
79
+ const isRelay = mode === 'relay'
80
+ const showLogin = isRelay && needsAuth && !authed
81
+
82
+ const nav = isRelay ? [
83
+ { to: '/', icon: Users, label: 'Team' },
84
+ ] : [
54
85
  { to: '/', icon: Activity, label: 'Dashboard' },
55
86
  { to: '/projects', icon: FolderOpen, label: 'Projects' },
56
87
  { to: '/sessions', icon: MessageSquare, label: 'Sessions' },
@@ -59,10 +90,16 @@ export default function App() {
59
90
  { to: '/sql', icon: Database, label: 'SQL' },
60
91
  ]
61
92
 
93
+ if (showLogin) {
94
+ return <LoginScreen onSuccess={() => setAuthed(true)} />
95
+ }
96
+
62
97
  return (
63
98
  <div className="min-h-screen">
64
99
  <header className="border-b px-4 py-1.5 flex items-center gap-3 sticky top-0 z-50 backdrop-blur-xl" style={{ borderColor: 'var(--c-border)', background: 'var(--c-header)' }}>
65
- <span className="text-xs font-bold tracking-tight" style={{ color: 'var(--c-white)' }}>npx agentlytics</span>
100
+ <span className="text-xs font-bold tracking-tight" style={{ color: 'var(--c-white)' }}>
101
+ npx agentlytics{isRelay && <span className="ml-1.5 text-[9px] font-medium px-1.5 py-0.5" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>relay</span>}
102
+ </span>
66
103
  <nav className="flex gap-0.5 ml-2">
67
104
  {nav.map(({ to, icon: Icon, label }) => (
68
105
  <NavLink
@@ -81,37 +118,52 @@ export default function App() {
81
118
  ))}
82
119
  </nav>
83
120
  <div className="ml-auto flex items-center gap-3">
84
- <button
85
- onClick={() => setLive(!live)}
86
- className="flex items-center gap-1.5 px-2 py-0.5 text-[10px] transition"
87
- style={{
88
- color: live ? '#22c55e' : 'var(--c-text3)',
89
- border: live ? '1px solid rgba(34,197,94,0.3)' : '1px solid var(--c-border)',
90
- background: live ? 'rgba(34,197,94,0.08)' : 'transparent',
91
- }}
92
- title={live ? 'Disable live refresh' : 'Enable live refresh (every 60s)'}
93
- >
94
- <span
95
- className={`inline-block w-1.5 h-1.5 rounded-full ${live ? 'pulse-dot' : ''}`}
96
- style={{ background: live ? '#22c55e' : 'var(--c-text3)' }}
97
- />
98
- Live
99
- </button>
100
- <button
101
- onClick={handleRefetch}
102
- disabled={!!refetchState}
103
- className="flex items-center gap-1 px-2 py-0.5 text-[10px] rounded transition hover:bg-[var(--c-card)]"
104
- style={{ color: 'var(--c-text2)', border: '1px solid var(--c-border)' }}
105
- title="Clear cache and rescan all editors"
106
- >
107
- <RefreshCw size={10} className={refetchState ? 'animate-spin' : ''} />
108
- {refetchState
109
- ? `Refetching (${refetchState.scanned}/${refetchState.total})...`
110
- : 'Refetch'}
111
- </button>
112
- <span className="text-[10px]" style={{ color: 'var(--c-text2)' }}>
113
- {overview ? `${overview.totalChats} sessions` : '...'}
114
- </span>
121
+ {!isRelay && (
122
+ <>
123
+ <button
124
+ onClick={() => setLive(!live)}
125
+ className="flex items-center gap-1.5 px-2 py-0.5 text-[10px] transition"
126
+ style={{
127
+ color: live ? '#22c55e' : 'var(--c-text3)',
128
+ border: live ? '1px solid rgba(34,197,94,0.3)' : '1px solid var(--c-border)',
129
+ background: live ? 'rgba(34,197,94,0.08)' : 'transparent',
130
+ }}
131
+ title={live ? 'Disable live refresh' : 'Enable live refresh (every 60s)'}
132
+ >
133
+ <span
134
+ className={`inline-block w-1.5 h-1.5 rounded-full ${live ? 'pulse-dot' : ''}`}
135
+ style={{ background: live ? '#22c55e' : 'var(--c-text3)' }}
136
+ />
137
+ Live
138
+ </button>
139
+ <button
140
+ onClick={handleRefetch}
141
+ disabled={!!refetchState}
142
+ className="flex items-center gap-1 px-2 py-0.5 text-[10px] rounded transition hover:bg-[var(--c-card)]"
143
+ style={{ color: 'var(--c-text2)', border: '1px solid var(--c-border)' }}
144
+ title="Clear cache and rescan all editors"
145
+ >
146
+ <RefreshCw size={10} className={refetchState ? 'animate-spin' : ''} />
147
+ {refetchState
148
+ ? `Refetching (${refetchState.scanned}/${refetchState.total})...`
149
+ : 'Refetch'}
150
+ </button>
151
+ <span className="text-[10px]" style={{ color: 'var(--c-text2)' }}>
152
+ {overview ? `${overview.totalChats} sessions` : '...'}
153
+ </span>
154
+ </>
155
+ )}
156
+ {isRelay && (
157
+ <button
158
+ onClick={() => { setMcpOpen(true); setMcpCopied(false) }}
159
+ className="flex items-center gap-1.5 px-2 py-0.5 text-[10px] transition hover:bg-[var(--c-card)]"
160
+ style={{ color: '#818cf8', border: '1px solid var(--c-border)' }}
161
+ title="MCP Connection"
162
+ >
163
+ <Plug size={10} />
164
+ Connect
165
+ </button>
166
+ )}
115
167
  <button
116
168
  onClick={toggle}
117
169
  className="p-1 rounded transition hover:bg-[var(--c-card)]"
@@ -131,16 +183,26 @@ export default function App() {
131
183
  )}
132
184
 
133
185
  <main className="p-4 max-w-[1400px] mx-auto">
134
- <Routes>
135
- <Route path="/" element={<Dashboard overview={overview} />} />
136
- <Route path="/projects" element={<Projects overview={overview} />} />
137
- <Route path="/projects/detail" element={<ProjectDetail />} />
138
- <Route path="/sessions" element={<Sessions overview={overview} />} />
139
- {/* ChatDetail is now a sidebar in Sessions */}
140
- <Route path="/analysis" element={<DeepAnalysis overview={overview} />} />
141
- <Route path="/compare" element={<Compare overview={overview} />} />
142
- <Route path="/sql" element={<SqlViewer />} />
143
- </Routes>
186
+ {mode === null ? (
187
+ <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading...</div>
188
+ ) : isRelay ? (
189
+ <Routes>
190
+ <Route path="/" element={<RelayDashboard />} />
191
+ <Route path="/relay" element={<RelayDashboard />} />
192
+ <Route path="/relay/user/:username" element={<RelayUserDetail />} />
193
+ </Routes>
194
+ ) : (
195
+ <Routes>
196
+ <Route path="/" element={<Dashboard overview={overview} />} />
197
+ <Route path="/projects" element={<Projects overview={overview} />} />
198
+ <Route path="/projects/detail" element={<ProjectDetail />} />
199
+ <Route path="/sessions" element={<Sessions overview={overview} />} />
200
+ {/* ChatDetail is now a sidebar in Sessions */}
201
+ <Route path="/analysis" element={<DeepAnalysis overview={overview} />} />
202
+ <Route path="/compare" element={<Compare overview={overview} />} />
203
+ <Route path="/sql" element={<SqlViewer />} />
204
+ </Routes>
205
+ )}
144
206
  </main>
145
207
 
146
208
  <footer className="border-t mt-8 px-4 py-3 flex items-center justify-between text-[10px]" style={{ borderColor: 'var(--c-border)', color: 'var(--c-text3)' }}>
@@ -158,6 +220,53 @@ export default function App() {
158
220
  built by <a href="https://github.com/f" target="_blank" rel="noopener noreferrer" className="hover:text-[var(--c-text)] transition" style={{ color: 'var(--c-text2)' }}>fkadev</a>
159
221
  </span>
160
222
  </footer>
223
+
224
+ {/* MCP Config Modal */}
225
+ {mcpOpen && (
226
+ <>
227
+ <div className="fixed inset-0 z-[60]" style={{ background: 'rgba(0,0,0,0.5)' }} onClick={() => setMcpOpen(false)} />
228
+ <div
229
+ className="fixed z-[70] w-[440px] max-w-[90vw] p-5 rounded shadow-2xl"
230
+ style={{ top: '50%', left: '50%', transform: 'translate(-50%, -50%)', background: 'var(--c-bg)', border: '1px solid var(--c-border)' }}
231
+ >
232
+ <div className="flex items-center justify-between mb-4">
233
+ <div className="text-[13px] font-bold" style={{ color: 'var(--c-white)' }}>
234
+ <Plug size={13} className="inline mr-1.5" style={{ color: '#818cf8' }} />
235
+ Connection Config
236
+ </div>
237
+ <button onClick={() => setMcpOpen(false)} className="text-[18px] leading-none px-1 hover:opacity-70 transition" style={{ color: 'var(--c-text3)' }}>&times;</button>
238
+ </div>
239
+
240
+ <div className="text-[11px] font-medium mb-1.5" style={{ color: 'var(--c-white)' }}>MCP Config</div>
241
+ <div className="flex items-center justify-between mb-1">
242
+ <div className="text-[9px]" style={{ color: 'var(--c-text3)' }}>Add to your AI client's MCP settings</div>
243
+ <button
244
+ onClick={() => {
245
+ const json = JSON.stringify({ "mcpServers": { "agentlytics": { "url": `${window.location.origin}/mcp` } } }, null, 2)
246
+ navigator.clipboard.writeText(json)
247
+ setMcpCopied(true)
248
+ setTimeout(() => setMcpCopied(false), 2000)
249
+ }}
250
+ className="flex items-center gap-1 px-1.5 py-0.5 text-[9px] transition hover:bg-[var(--c-bg3)]"
251
+ style={{ border: '1px solid var(--c-border)', color: mcpCopied ? '#22c55e' : 'var(--c-text2)' }}
252
+ >
253
+ {mcpCopied ? <><Check size={9} /> Copied</> : <><Copy size={9} /> Copy</>}
254
+ </button>
255
+ </div>
256
+ <pre
257
+ className="text-[10px] px-3 py-2 overflow-x-auto mb-4"
258
+ style={{ background: 'var(--c-bg3)', border: '1px solid var(--c-border)', color: 'var(--c-text)', fontFamily: 'JetBrains Mono, monospace', lineHeight: 1.6 }}
259
+ >{`{\n "mcpServers": {\n "agentlytics": {\n "url": "${window.location.origin}/mcp"\n }\n }\n}`}</pre>
260
+
261
+ <div className="text-[11px] font-medium mb-1.5" style={{ color: 'var(--c-white)' }}>Join Command</div>
262
+ <div className="text-[9px] mb-1" style={{ color: 'var(--c-text3)' }}>Share with your team to start syncing sessions</div>
263
+ <pre
264
+ className="text-[10px] px-3 py-2 overflow-x-auto"
265
+ style={{ background: 'var(--c-bg3)', border: '1px solid var(--c-border)', color: 'var(--c-text)', fontFamily: 'JetBrains Mono, monospace', lineHeight: 1.6 }}
266
+ >{`cd /path/to/your-project\nRELAY_PASSWORD=${relayPassword || '<pass>'} npx agentlytics --join ${window.location.host}`}</pre>
267
+ </div>
268
+ </>
269
+ )}
161
270
  </div>
162
271
  )
163
272
  }
@@ -1,170 +1,41 @@
1
1
  import { useState, useEffect, useRef } from 'react'
2
- import { X, User, Bot, Wrench, Settings, Play, CheckCircle, ChevronRight, ChevronDown, Download, Send, Search } from 'lucide-react'
3
- import ReactMarkdown from 'react-markdown'
4
- import remarkGfm from 'remark-gfm'
2
+ import { X, Download, Send, Search } from 'lucide-react'
5
3
  import { fetchChat, BASE } from '../lib/api'
6
4
  import { editorColor, editorLabel, formatDateTime, formatNumber } from '../lib/constants'
5
+ import MessageContent, { ROLE_CONFIG } from './MessageRenderer'
7
6
 
8
- const ROLE_CONFIG = {
9
- user: { icon: User, label: 'User', borderColor: 'rgba(34,197,94,0.2)', bg: 'rgba(34,197,94,0.05)' },
10
- assistant: { icon: Bot, label: 'Assistant', borderColor: 'rgba(99,102,241,0.2)', bg: 'rgba(99,102,241,0.05)' },
11
- system: { icon: Settings, label: 'System', borderColor: 'rgba(107,114,128,0.2)', bg: 'rgba(107,114,128,0.05)' },
12
- tool: { icon: Wrench, label: 'Tool', borderColor: 'rgba(234,179,8,0.2)', bg: 'rgba(234,179,8,0.05)' },
13
- }
14
-
15
- function parseContent(content) {
16
- const segments = []
17
- const regex = /\[tool-call: ([^\]]+)\]|\[tool-result: ([^\]]+)\]\s*(.*?)(?=\n\[tool-|$)/gs
18
- let lastIdx = 0
19
- let match
20
- while ((match = regex.exec(content)) !== null) {
21
- if (match.index > lastIdx) {
22
- const text = content.slice(lastIdx, match.index).trim()
23
- if (text) segments.push({ type: 'text', value: text })
24
- }
25
- if (match[1]) {
26
- segments.push({ type: 'tool-call', name: match[1].replace(/\(.*\)$/, '').trim(), args: match[1] })
27
- } else if (match[2]) {
28
- segments.push({ type: 'tool-result', name: match[2].trim(), preview: (match[3] || '').trim() })
29
- }
30
- lastIdx = match.index + match[0].length
31
- }
32
- if (lastIdx < content.length) {
33
- const text = content.slice(lastIdx).trim()
34
- if (text) segments.push({ type: 'text', value: text })
35
- }
36
- return segments.length > 0 ? segments : [{ type: 'text', value: content }]
37
- }
38
-
39
- function summarizeToolArgs(name, args) {
40
- if (!args || typeof args !== 'object') return ''
41
- if (args.file_path || args.TargetFile) return args.file_path || args.TargetFile
42
- if (args.CommandLine || args.command) return args.CommandLine || args.command
43
- if (args.Query || args.query) return `${args.Query || args.query}${args.SearchPath ? ` in ${args.SearchPath}` : ''}`
44
- if (args.Url || args.url) return args.Url || args.url
45
- const vals = Object.values(args).filter(v => typeof v === 'string' && v.length > 0 && v.length < 120)
46
- return vals.length > 0 ? vals[0] : ''
47
- }
48
-
49
- function ToolArgsDiff({ args }) {
50
- const old = args.old_string || args.old_text || args.oldText || args.search || null
51
- const nw = args.new_string || args.new_text || args.newText || args.replace || null
52
- if (old == null && nw == null) return null
53
- const maxLines = 8
54
- const oldLines = (old || '').split('\n').slice(0, maxLines)
55
- const newLines = (nw || '').split('\n').slice(0, maxLines)
56
- return (
57
- <div className="mt-1 text-[9px] font-mono overflow-x-auto" style={{ border: '1px solid var(--c-border)' }}>
58
- {(args.file_path || args.TargetFile) && (
59
- <div className="px-2 py-0.5" style={{ background: 'var(--c-code-bg)', color: 'var(--c-text)' }}>{args.file_path || args.TargetFile}</div>
60
- )}
61
- {old && oldLines.map((line, i) => (
62
- <div key={'o' + i} className="px-2" style={{ background: 'rgba(248,113,113,0.1)', color: '#f87171' }}>
63
- <span style={{ color: 'var(--c-text3)', userSelect: 'none' }}>- </span>{line}
64
- </div>
65
- ))}
66
- {old && oldLines.length < (old || '').split('\n').length && (
67
- <div className="px-2" style={{ color: 'var(--c-text3)' }}> ... {(old || '').split('\n').length - maxLines} more lines</div>
68
- )}
69
- {nw && newLines.map((line, i) => (
70
- <div key={'n' + i} className="px-2" style={{ background: 'rgba(52,211,153,0.1)', color: '#34d399' }}>
71
- <span style={{ color: 'var(--c-text3)', userSelect: 'none' }}>+ </span>{line}
72
- </div>
73
- ))}
74
- {nw && newLines.length < (nw || '').split('\n').length && (
75
- <div className="px-2" style={{ color: 'var(--c-text3)' }}> ... {(nw || '').split('\n').length - maxLines} more lines</div>
76
- )}
77
- </div>
78
- )
79
- }
80
-
81
- function ToolArgsDetail({ args }) {
82
- if (!args || Object.keys(args).length === 0) return null
83
- const hasDiff = args.old_string || args.new_string || args.old_text || args.new_text || args.search || args.replace
84
- if (hasDiff) return <ToolArgsDiff args={args} />
85
- const file = args.file_path || args.TargetFile || args.filePath || args.path || null
86
- const cmd = args.CommandLine || args.command || null
87
- const query = args.Query || args.query || args.search_term || null
88
- const url = args.Url || args.url || null
89
- return (
90
- <div className="mt-1 text-[9px] font-mono overflow-x-auto" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
91
- {file && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>file: {file}</div>}
92
- {cmd && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>cmd: {cmd}</div>}
93
- {query && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>query: {query}</div>}
94
- {url && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>url: {url}</div>}
95
- {!file && !cmd && !query && !url && (
96
- <pre className="px-2 py-1 whitespace-pre-wrap break-all" style={{ color: 'var(--c-text2)' }}>{JSON.stringify(args, null, 2)}</pre>
97
- )}
98
- </div>
99
- )
100
- }
101
-
102
- function ToolCallBlock({ name, args, detail }) {
103
- const [open, setOpen] = useState(false)
104
- const hasDetail = detail && Object.keys(detail).length > 0
105
- return (
106
- <div className="my-1 px-2 py-1 text-[10px]" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
107
- <div className="flex items-center gap-1.5 cursor-pointer" onClick={() => hasDetail && setOpen(!open)}>
108
- {hasDetail
109
- ? (open ? <ChevronDown size={9} style={{ color: '#a78bfa' }} /> : <ChevronRight size={9} style={{ color: '#a78bfa' }} />)
110
- : <Play size={9} style={{ color: '#a78bfa' }} />
111
- }
112
- <span className="font-bold" style={{ color: 'var(--c-white)' }}>{name}</span>
113
- {args !== name && !hasDetail && <span className="truncate" style={{ color: 'var(--c-text2)' }}>{args}</span>}
114
- {hasDetail && <span className="truncate" style={{ color: 'var(--c-text2)' }}>{summarizeToolArgs(name, detail)}</span>}
115
- </div>
116
- {open && hasDetail && <ToolArgsDetail args={detail} />}
117
- </div>
118
- )
119
- }
120
-
121
- function ToolResultBlock({ name, preview }) {
122
- const [open, setOpen] = useState(false)
123
- const isNoisy = preview.length > 120 || preview.startsWith('{') || preview.includes('contentId')
124
- const short = isNoisy ? `${name} completed` : preview.substring(0, 120)
125
- return (
126
- <div className="my-1 px-2 py-1 text-[10px]" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
127
- <div className="flex items-center gap-1.5 cursor-pointer" onClick={() => preview && setOpen(!open)}>
128
- <CheckCircle size={9} style={{ color: '#34d399' }} />
129
- <span className="truncate" style={{ color: 'var(--c-text)' }}>{short}</span>
130
- {isNoisy && preview && <span style={{ color: 'var(--c-text3)' }}>{open ? '[-]' : '[+]'}</span>}
131
- </div>
132
- {open && <pre className="mt-1 text-[9px] overflow-x-auto whitespace-pre-wrap break-all" style={{ color: 'var(--c-text2)' }}>{preview}</pre>}
133
- </div>
134
- )
135
- }
136
-
137
- function MessageContent({ content, toolCallDetails }) {
138
- const segments = parseContent(content)
139
- let toolIdx = 0
140
- return segments.map((seg, i) => {
141
- if (seg.type === 'tool-call') {
142
- const detail = toolCallDetails ? toolCallDetails.find(tc => tc.name === seg.name && toolCallDetails.indexOf(tc) >= toolIdx) : null
143
- if (detail) toolIdx = toolCallDetails.indexOf(detail) + 1
144
- return <ToolCallBlock key={i} name={seg.name} args={seg.args} detail={detail?.args} />
145
- }
146
- if (seg.type === 'tool-result') return <ToolResultBlock key={i} name={seg.name} preview={seg.preview} />
147
- return <div key={i} className="md-body text-[12px] leading-relaxed"><ReactMarkdown remarkPlugins={[remarkGfm]}>{seg.value}</ReactMarkdown></div>
148
- })
149
- }
150
-
151
- export default function ChatSidebar({ chatId, onClose }) {
7
+ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, username }) {
152
8
  const [chat, setChat] = useState(null)
153
9
  const [loading, setLoading] = useState(true)
154
10
  const [msgFilter, setMsgFilter] = useState('')
155
11
  const scrollRef = useRef(null)
12
+ const msgCountRef = useRef(0)
156
13
 
157
14
  useEffect(() => {
158
15
  if (!chatId) return
159
16
  setLoading(true)
160
17
  setChat(null)
161
- fetchChat(chatId).then(data => {
18
+ msgCountRef.current = 0
19
+ const doFetch = fetchFn || fetchChat
20
+ doFetch(chatId).then(data => {
162
21
  setChat(data)
22
+ msgCountRef.current = data?.messages?.length || 0
163
23
  setLoading(false)
164
24
  })
165
- }, [chatId])
166
25
 
167
- // Scroll to bottom when chat loads
26
+ // Poll for new messages while sidebar is open
27
+ const iv = setInterval(() => {
28
+ doFetch(chatId).then(data => {
29
+ if (data?.messages?.length !== msgCountRef.current) {
30
+ msgCountRef.current = data?.messages?.length || 0
31
+ setChat(data)
32
+ }
33
+ }).catch(() => {})
34
+ }, 10000)
35
+ return () => clearInterval(iv)
36
+ }, [chatId, fetchFn])
37
+
38
+ // Scroll to bottom when chat loads or new messages arrive
168
39
  useEffect(() => {
169
40
  if (!loading && chat && scrollRef.current) {
170
41
  scrollRef.current.scrollTop = scrollRef.current.scrollHeight
@@ -216,7 +87,7 @@ export default function ChatSidebar({ chatId, onClose }) {
216
87
  </div>
217
88
  </div>
218
89
  )}
219
- {chat && (
90
+ {chat && !fetchFn && (
220
91
  <a
221
92
  href={`${BASE}/api/chats/${chat.id}/markdown`}
222
93
  download
@@ -226,16 +97,17 @@ export default function ChatSidebar({ chatId, onClose }) {
226
97
  <Download size={11} /> .md
227
98
  </a>
228
99
  )}
100
+ {extraHeader}
229
101
  </div>
230
102
 
231
103
  {/* Stats row */}
232
104
  {chat?.stats && (
233
105
  <div className="flex items-center gap-3 px-4 py-2 text-[10px] shrink-0" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text2)' }}>
234
106
  <span>{chat.stats.totalMessages} msgs</span>
235
- <span>{chat.stats.toolCalls.length} tools</span>
107
+ {chat.stats.toolCalls?.length > 0 && <span>{chat.stats.toolCalls.length} tools</span>}
236
108
  {chat.stats.totalInputTokens > 0 && <span>{formatNumber(chat.stats.totalInputTokens)} in</span>}
237
109
  {chat.stats.totalOutputTokens > 0 && <span>{formatNumber(chat.stats.totalOutputTokens)} out</span>}
238
- {chat.stats.models.length > 0 && (
110
+ {chat.stats.models?.length > 0 && (
239
111
  <span className="ml-auto font-mono truncate" style={{ color: 'var(--c-accent)', opacity: 0.7 }}>
240
112
  {[...new Set(chat.stats.models)].join(', ')}
241
113
  </span>
@@ -279,11 +151,11 @@ export default function ChatSidebar({ chatId, onClose }) {
279
151
  <div key={i} className="rounded-r px-3 py-2" style={{ borderLeft: `2px solid ${cfg.borderColor}`, background: cfg.bg }}>
280
152
  <div className="flex items-center gap-1.5 text-[10px] mb-1" style={{ color: 'var(--c-text2)' }}>
281
153
  <Icon size={11} />
282
- <span className="font-medium">{cfg.label}</span>
154
+ <span className="font-medium">{msg.role === 'user' && (username || chat?.username) ? (username || chat.username) : cfg.label}</span>
283
155
  {msg.model && <span className="font-mono" style={{ color: 'var(--c-accent)', opacity: 0.6 }}>· {msg.model}</span>}
284
156
  </div>
285
157
  <div className="text-[12px]" style={{ color: 'var(--c-text)' }}>
286
- <MessageContent content={msg.content} toolCallDetails={chat.toolCallDetails} />
158
+ <MessageContent content={msg.content} toolCallDetails={chat.toolCallDetails || []} />
287
159
  </div>
288
160
  </div>
289
161
  )
@@ -0,0 +1,22 @@
1
+ import EditorDot from './EditorDot'
2
+ import { editorColor, editorLabel } from '../lib/constants'
3
+
4
+ export default function EditorBreakdown({ editors, total }) {
5
+ return (
6
+ <div className="space-y-1.5">
7
+ {editors.map(([src, count]) => {
8
+ const pct = total > 0 ? (count / total * 100) : 0
9
+ return (
10
+ <div key={src} className="flex items-center gap-2">
11
+ <EditorDot source={src} size={8} />
12
+ <span className="text-[10px] w-24" style={{ color: 'var(--c-text)' }}>{editorLabel(src)}</span>
13
+ <div className="flex-1 h-2 relative" style={{ background: 'var(--c-card)' }}>
14
+ <div className="h-full" style={{ width: `${pct}%`, background: editorColor(src), opacity: 0.7 }} />
15
+ </div>
16
+ <span className="text-[10px] w-10 text-right" style={{ color: 'var(--c-text2)' }}>{count}</span>
17
+ </div>
18
+ )
19
+ })}
20
+ </div>
21
+ )
22
+ }
@@ -0,0 +1,138 @@
1
+ import { useState, useEffect, useRef } from 'react'
2
+ import { Radio, MessageSquare, FolderOpen, Cpu } from 'lucide-react'
3
+ import EditorDot from './EditorDot'
4
+ import { editorLabel, formatNumber } from '../lib/constants'
5
+ import { fetchRelayFeed } from '../lib/api'
6
+
7
+ function timeAgo(ts) {
8
+ if (!ts) return ''
9
+ const diff = Date.now() - ts
10
+ if (diff < 60000) return 'just now'
11
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
12
+ if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
13
+ return `${Math.floor(diff / 86400000)}d ago`
14
+ }
15
+
16
+ function timeLabel(ts) {
17
+ if (!ts) return ''
18
+ const d = new Date(ts)
19
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
20
+ }
21
+
22
+ export default function LiveFeed({ onSessionClick }) {
23
+ const [items, setItems] = useState([])
24
+ const scrollRef = useRef(null)
25
+ const prevCountRef = useRef(0)
26
+
27
+ useEffect(() => {
28
+ const load = () => {
29
+ fetchRelayFeed({ limit: 80 })
30
+ .then(data => {
31
+ if (Array.isArray(data)) setItems(data)
32
+ })
33
+ .catch(() => {})
34
+ }
35
+ load()
36
+ const iv = setInterval(load, 10000)
37
+ return () => clearInterval(iv)
38
+ }, [])
39
+
40
+ // Group items by relative time buckets
41
+ const now = Date.now()
42
+ const buckets = []
43
+ let currentBucket = null
44
+
45
+ for (const item of items) {
46
+ const diff = now - item.lastUpdatedAt
47
+ let label
48
+ if (diff < 300000) label = 'Just now'
49
+ else if (diff < 3600000) label = `${Math.floor(diff / 60000)} min ago`
50
+ else if (diff < 86400000) label = `${Math.floor(diff / 3600000)}h ago`
51
+ else label = new Date(item.lastUpdatedAt).toLocaleDateString()
52
+
53
+ if (!currentBucket || currentBucket.label !== label) {
54
+ currentBucket = { label, items: [] }
55
+ buckets.push(currentBucket)
56
+ }
57
+ currentBucket.items.push(item)
58
+ }
59
+
60
+ return (
61
+ <div className="flex flex-col h-full">
62
+ {/* Header */}
63
+ <div className="flex items-center gap-2 px-3 py-2.5 shrink-0" style={{ borderBottom: '1px solid var(--c-border)' }}>
64
+ <Radio size={12} style={{ color: '#22c55e' }} />
65
+ <span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--c-text2)' }}>Live Feed</span>
66
+ <span className="inline-block w-1.5 h-1.5 rounded-full pulse-dot ml-auto" style={{ background: '#22c55e' }} />
67
+ </div>
68
+
69
+ {/* Feed */}
70
+ <div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-thin">
71
+ {items.length === 0 && (
72
+ <div className="text-[11px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>
73
+ No recent activity
74
+ </div>
75
+ )}
76
+
77
+ {buckets.map((bucket, bi) => (
78
+ <div key={bi}>
79
+ {/* Time separator */}
80
+ <div className="sticky top-0 px-3 py-1.5 text-[9px] font-medium uppercase tracking-wider" style={{ background: 'var(--c-bg)', color: 'var(--c-text3)', borderBottom: '1px solid var(--c-border)' }}>
81
+ {bucket.label}
82
+ </div>
83
+
84
+ {bucket.items.map((item) => (
85
+ <div
86
+ key={`${item.id}-${item.username}`}
87
+ className="px-3 py-2.5 cursor-pointer transition hover:bg-[var(--c-card)]"
88
+ style={{ borderBottom: '1px solid var(--c-border)' }}
89
+ onClick={() => onSessionClick && onSessionClick(item.id, item.username)}
90
+ >
91
+ {/* Session name */}
92
+ <div className="text-[11px] font-medium truncate mb-1" style={{ color: 'var(--c-white)' }}>
93
+ {item.name || 'Untitled'}
94
+ </div>
95
+
96
+ {/* User + editor row */}
97
+ <div className="flex items-center gap-1.5 mb-1">
98
+ <span
99
+ className="text-[9px] font-medium px-1 py-0.5 shrink-0 truncate max-w-[120px]"
100
+ style={{ background: 'rgba(99,102,241,0.12)', color: '#818cf8' }}
101
+ title={item.username}
102
+ >
103
+ {item.username}
104
+ </span>
105
+ <EditorDot source={item.source} size={6} />
106
+ <span className="text-[9px] truncate" style={{ color: 'var(--c-text2)' }}>{editorLabel(item.source)}</span>
107
+ <span className="text-[9px] ml-auto shrink-0" style={{ color: 'var(--c-text3)' }}>{timeLabel(item.lastUpdatedAt)}</span>
108
+ </div>
109
+
110
+ {/* Meta row */}
111
+ <div className="flex items-center gap-2 text-[9px]" style={{ color: 'var(--c-text3)' }}>
112
+ {item.totalMessages > 0 && (
113
+ <span className="flex items-center gap-0.5">
114
+ <MessageSquare size={8} /> {item.totalMessages}
115
+ </span>
116
+ )}
117
+ {item.folder && (
118
+ <span className="flex items-center gap-0.5 truncate">
119
+ <FolderOpen size={8} /> {item.folder.split('/').pop()}
120
+ </span>
121
+ )}
122
+ {item.mode && (
123
+ <span className="px-1 py-0" style={{ background: 'rgba(168,85,247,0.1)', color: '#a855f7' }}>{item.mode}</span>
124
+ )}
125
+ {item.models?.[0] && (
126
+ <span className="flex items-center gap-0.5 ml-auto truncate" style={{ color: '#818cf8' }}>
127
+ <Cpu size={8} /> {item.models[0]}
128
+ </span>
129
+ )}
130
+ </div>
131
+ </div>
132
+ ))}
133
+ </div>
134
+ ))}
135
+ </div>
136
+ </div>
137
+ )
138
+ }