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.
@@ -0,0 +1,79 @@
1
+ import { useState } from 'react'
2
+ import { Lock } from 'lucide-react'
3
+ import { login } from '../lib/api'
4
+
5
+ export default function LoginScreen({ onSuccess }) {
6
+ const [password, setPassword] = useState('')
7
+ const [error, setError] = useState(null)
8
+ const [loading, setLoading] = useState(false)
9
+
10
+ const handleSubmit = async (e) => {
11
+ e.preventDefault()
12
+ setError(null)
13
+ setLoading(true)
14
+ try {
15
+ await login(password)
16
+ onSuccess()
17
+ } catch (err) {
18
+ setError(err.message || 'Invalid password')
19
+ }
20
+ setLoading(false)
21
+ }
22
+
23
+ return (
24
+ <div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--c-bg)' }}>
25
+ <div className="w-full max-w-xs">
26
+ <div className="card p-6">
27
+ <div className="flex flex-col items-center mb-5">
28
+ <div
29
+ className="w-10 h-10 flex items-center justify-center rounded-full mb-3"
30
+ style={{ background: 'rgba(99,102,241,0.12)' }}
31
+ >
32
+ <Lock size={18} style={{ color: '#818cf8' }} />
33
+ </div>
34
+ <div className="text-sm font-bold" style={{ color: 'var(--c-white)' }}>
35
+ npx agentlytics
36
+ <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' }}>
37
+ relay
38
+ </span>
39
+ </div>
40
+ <div className="text-[10px] mt-1" style={{ color: 'var(--c-text3)' }}>
41
+ This relay is password-protected
42
+ </div>
43
+ </div>
44
+
45
+ <form onSubmit={handleSubmit} className="space-y-3">
46
+ <input
47
+ type="password"
48
+ value={password}
49
+ onChange={e => setPassword(e.target.value)}
50
+ placeholder="Password"
51
+ autoFocus
52
+ className="w-full px-3 py-2 text-[12px] outline-none rounded"
53
+ style={{
54
+ background: 'var(--c-bg3)',
55
+ color: 'var(--c-white)',
56
+ border: error ? '1px solid #f87171' : '1px solid var(--c-border)',
57
+ }}
58
+ />
59
+ {error && (
60
+ <div className="text-[10px]" style={{ color: '#f87171' }}>{error}</div>
61
+ )}
62
+ <button
63
+ type="submit"
64
+ disabled={loading || !password}
65
+ className="w-full py-2 text-[11px] font-medium rounded transition"
66
+ style={{
67
+ background: '#6366f1',
68
+ color: '#fff',
69
+ opacity: loading || !password ? 0.5 : 1,
70
+ }}
71
+ >
72
+ {loading ? 'Signing in...' : 'Sign in'}
73
+ </button>
74
+ </form>
75
+ </div>
76
+ </div>
77
+ </div>
78
+ )
79
+ }
@@ -0,0 +1,167 @@
1
+ import { useState } from 'react'
2
+ import { User, Bot, Wrench, Settings, Play, CheckCircle, ChevronRight, ChevronDown } from 'lucide-react'
3
+ import ReactMarkdown from 'react-markdown'
4
+ import remarkGfm from 'remark-gfm'
5
+
6
+ export const ROLE_CONFIG = {
7
+ user: { icon: User, label: 'User', borderColor: 'rgba(34,197,94,0.2)', bg: 'rgba(34,197,94,0.05)' },
8
+ assistant: { icon: Bot, label: 'Assistant', borderColor: 'rgba(99,102,241,0.2)', bg: 'rgba(99,102,241,0.05)' },
9
+ system: { icon: Settings, label: 'System', borderColor: 'rgba(107,114,128,0.2)', bg: 'rgba(107,114,128,0.05)' },
10
+ tool: { icon: Wrench, label: 'Tool', borderColor: 'rgba(234,179,8,0.2)', bg: 'rgba(234,179,8,0.05)' },
11
+ }
12
+
13
+ export function parseContent(content) {
14
+ const segments = []
15
+ const regex = /\[tool-call: ([^\]]+)\]|\[tool-result: ([^\]]+)\]\s*(.*?)(?=\n\[tool-|$)/gs
16
+ let lastIdx = 0
17
+ let match
18
+ while ((match = regex.exec(content)) !== null) {
19
+ if (match.index > lastIdx) {
20
+ const text = content.slice(lastIdx, match.index).trim()
21
+ if (text) segments.push({ type: 'text', value: text })
22
+ }
23
+ if (match[1]) {
24
+ segments.push({ type: 'tool-call', name: match[1].replace(/\(.*\)$/, '').trim(), args: match[1] })
25
+ } else if (match[2]) {
26
+ segments.push({ type: 'tool-result', name: match[2].trim(), preview: (match[3] || '').trim() })
27
+ }
28
+ lastIdx = match.index + match[0].length
29
+ }
30
+ if (lastIdx < content.length) {
31
+ const text = content.slice(lastIdx).trim()
32
+ if (text) segments.push({ type: 'text', value: text })
33
+ }
34
+ return segments.length > 0 ? segments : [{ type: 'text', value: content }]
35
+ }
36
+
37
+ export function summarizeToolArgs(name, args) {
38
+ if (!args || typeof args !== 'object') return ''
39
+ if (args.file_path || args.TargetFile) return args.file_path || args.TargetFile
40
+ if (args.CommandLine || args.command) return args.CommandLine || args.command
41
+ if (args.Query || args.query) return `${args.Query || args.query}${args.SearchPath ? ` in ${args.SearchPath}` : ''}`
42
+ if (args.Url || args.url) return args.Url || args.url
43
+ const vals = Object.values(args).filter(v => typeof v === 'string' && v.length > 0 && v.length < 120)
44
+ return vals.length > 0 ? vals[0] : ''
45
+ }
46
+
47
+ export function ToolArgsDiff({ args }) {
48
+ const old = args.old_string || args.old_text || args.oldText || args.search || null
49
+ const nw = args.new_string || args.new_text || args.newText || args.replace || null
50
+ if (old == null && nw == null) return null
51
+ const maxLines = 12
52
+ const oldLines = (old || '').split('\n').slice(0, maxLines)
53
+ const newLines = (nw || '').split('\n').slice(0, maxLines)
54
+ return (
55
+ <div className="mt-1.5 text-[9px] font-mono overflow-x-auto" style={{ border: '1px solid var(--c-border)' }}>
56
+ {(args.file_path || args.TargetFile) && (
57
+ <div className="px-2 py-0.5" style={{ background: 'var(--c-code-bg)', color: 'var(--c-text)' }}>{args.file_path || args.TargetFile}</div>
58
+ )}
59
+ {old && oldLines.map((line, i) => (
60
+ <div key={'o' + i} className="px-2" style={{ background: 'rgba(248,113,113,0.1)', color: '#f87171' }}>
61
+ <span style={{ color: 'var(--c-text3)', userSelect: 'none' }}>- </span>{line}
62
+ </div>
63
+ ))}
64
+ {old && oldLines.length < (old || '').split('\n').length && (
65
+ <div className="px-2" style={{ color: 'var(--c-text3)' }}> ... {(old || '').split('\n').length - maxLines} more lines</div>
66
+ )}
67
+ {nw && newLines.map((line, i) => (
68
+ <div key={'n' + i} className="px-2" style={{ background: 'rgba(52,211,153,0.1)', color: '#34d399' }}>
69
+ <span style={{ color: 'var(--c-text3)', userSelect: 'none' }}>+ </span>{line}
70
+ </div>
71
+ ))}
72
+ {nw && newLines.length < (nw || '').split('\n').length && (
73
+ <div className="px-2" style={{ color: 'var(--c-text3)' }}> ... {(nw || '').split('\n').length - maxLines} more lines</div>
74
+ )}
75
+ </div>
76
+ )
77
+ }
78
+
79
+ export function ToolArgsDetail({ args }) {
80
+ if (!args || Object.keys(args).length === 0) return null
81
+ const hasDiff = args.old_string || args.new_string || args.old_text || args.new_text || args.search || args.replace
82
+ if (hasDiff) return <ToolArgsDiff args={args} />
83
+ const file = args.file_path || args.TargetFile || args.filePath || args.path || null
84
+ const cmd = args.CommandLine || args.command || null
85
+ const query = args.Query || args.query || args.search_term || null
86
+ const url = args.Url || args.url || null
87
+ return (
88
+ <div className="mt-1.5 text-[9px] font-mono overflow-x-auto" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
89
+ {file && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>file: {file}</div>}
90
+ {cmd && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>cmd: {cmd}</div>}
91
+ {query && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>query: {query}</div>}
92
+ {url && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>url: {url}</div>}
93
+ {!file && !cmd && !query && !url && (
94
+ <pre className="px-2 py-1 whitespace-pre-wrap break-all" style={{ color: 'var(--c-text2)' }}>{JSON.stringify(args, null, 2)}</pre>
95
+ )}
96
+ </div>
97
+ )
98
+ }
99
+
100
+ export function ToolCallBlock({ name, args, detail }) {
101
+ const [open, setOpen] = useState(false)
102
+ const hasDetail = detail && Object.keys(detail).length > 0
103
+ return (
104
+ <div className="my-1 px-2.5 py-1.5 text-[10px]" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
105
+ <div className="flex items-center gap-2 cursor-pointer" onClick={() => hasDetail && setOpen(!open)}>
106
+ {hasDetail
107
+ ? (open ? <ChevronDown size={10} style={{ color: '#a78bfa' }} /> : <ChevronRight size={10} style={{ color: '#a78bfa' }} />)
108
+ : <Play size={10} style={{ color: '#a78bfa' }} />
109
+ }
110
+ <span className="font-bold" style={{ color: 'var(--c-white)' }}>{name}</span>
111
+ {args !== name && !hasDetail && <span className="truncate" style={{ color: 'var(--c-text2)' }}>{args}</span>}
112
+ {hasDetail && <span className="truncate" style={{ color: 'var(--c-text2)' }}>{summarizeToolArgs(name, detail)}</span>}
113
+ </div>
114
+ {open && hasDetail && <ToolArgsDetail args={detail} />}
115
+ </div>
116
+ )
117
+ }
118
+
119
+ export function ToolResultBlock({ name, preview }) {
120
+ const [open, setOpen] = useState(false)
121
+ const isNoisy = preview.length > 120 || preview.startsWith('{') || preview.includes('contentId')
122
+ const short = isNoisy ? `${name} completed` : preview.substring(0, 120)
123
+ return (
124
+ <div className="my-1 px-2.5 py-1.5 text-[10px]" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
125
+ <div className="flex items-center gap-2 cursor-pointer" onClick={() => preview && setOpen(!open)}>
126
+ <CheckCircle size={10} style={{ color: '#34d399' }} />
127
+ <span className="truncate" style={{ color: 'var(--c-text)' }}>{short}</span>
128
+ {isNoisy && preview && <span style={{ color: 'var(--c-text3)' }}>{open ? '[-]' : '[+]'}</span>}
129
+ </div>
130
+ {open && <pre className="mt-1 text-[9px] overflow-x-auto whitespace-pre-wrap break-all" style={{ color: 'var(--c-text2)' }}>{preview}</pre>}
131
+ </div>
132
+ )
133
+ }
134
+
135
+ export default function MessageContent({ content, toolCallDetails }) {
136
+ const segments = parseContent(content)
137
+ let toolIdx = 0
138
+ return segments.map((seg, i) => {
139
+ if (seg.type === 'tool-call') {
140
+ const detail = toolCallDetails ? toolCallDetails.find(tc => tc.name === seg.name && toolCallDetails.indexOf(tc) >= toolIdx) : null
141
+ if (detail) toolIdx = toolCallDetails.indexOf(detail) + 1
142
+ return <ToolCallBlock key={i} name={seg.name} args={seg.args} detail={detail?.args} />
143
+ }
144
+ if (seg.type === 'tool-result') return <ToolResultBlock key={i} name={seg.name} preview={seg.preview} />
145
+ return <div key={i} className="md-body"><ReactMarkdown remarkPlugins={[remarkGfm]}>{seg.value}</ReactMarkdown></div>
146
+ })
147
+ }
148
+
149
+ /**
150
+ * Renders a single message bubble with role icon, model tag, and content.
151
+ */
152
+ export function MessageBubble({ msg, toolCallDetails }) {
153
+ const cfg = ROLE_CONFIG[msg.role] || ROLE_CONFIG.system
154
+ const Icon = cfg.icon
155
+ return (
156
+ <div className="rounded-r-lg px-4 py-3" style={{ borderLeft: `2px solid ${cfg.borderColor}`, background: cfg.bg }}>
157
+ <div className="flex items-center gap-2 text-xs mb-2" style={{ color: 'var(--c-text2)' }}>
158
+ <Icon size={13} />
159
+ <span className="font-medium">{cfg.label}</span>
160
+ {msg.model && <span className="font-mono" style={{ color: 'var(--c-accent)', opacity: 0.6 }}>ยท {msg.model}</span>}
161
+ </div>
162
+ <div className="text-sm" style={{ color: 'var(--c-text)' }}>
163
+ <MessageContent content={msg.content} toolCallDetails={toolCallDetails} />
164
+ </div>
165
+ </div>
166
+ )
167
+ }
@@ -0,0 +1,23 @@
1
+ import { Cpu } from 'lucide-react'
2
+
3
+ export default function ModelBreakdown({ models }) {
4
+ const max = models[0]?.[1] || 1
5
+ return (
6
+ <div className="space-y-1.5">
7
+ {models.map(([name, count]) => {
8
+ const pct = (count / max * 100)
9
+ return (
10
+ <div key={name} className="flex items-center gap-2">
11
+ <Cpu size={10} style={{ color: '#818cf8' }} />
12
+ <span className="text-[10px] truncate w-40" style={{ color: 'var(--c-text)' }}>{name}</span>
13
+ <div className="flex-1 h-2 relative" style={{ background: 'var(--c-card)' }}>
14
+ <div className="h-full" style={{ width: `${pct}%`, background: '#6366f1', opacity: 0.5 }} />
15
+ </div>
16
+ <span className="text-[10px] w-10 text-right" style={{ color: 'var(--c-text2)' }}>{count}</span>
17
+ </div>
18
+ )
19
+ })}
20
+ {models.length === 0 && <div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>No model data</div>}
21
+ </div>
22
+ )
23
+ }
@@ -0,0 +1,3 @@
1
+ export default function SectionTitle({ children }) {
2
+ return <h3 className="text-[10px] font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--c-text2)' }}>{children}</h3>
3
+ }
package/ui/src/lib/api.js CHANGED
@@ -1,5 +1,49 @@
1
1
  export const BASE = '';
2
2
 
3
+ // โ”€โ”€ Auth helpers โ”€โ”€
4
+ const AUTH_KEY = 'agentlytics_relay_token';
5
+
6
+ export function getAuthToken() {
7
+ return localStorage.getItem(AUTH_KEY);
8
+ }
9
+
10
+ export function setAuthToken(token) {
11
+ if (token) localStorage.setItem(AUTH_KEY, token);
12
+ else localStorage.removeItem(AUTH_KEY);
13
+ }
14
+
15
+ let onAuthFailure = null;
16
+ export function setOnAuthFailure(fn) { onAuthFailure = fn; }
17
+
18
+ async function authFetch(url, opts = {}) {
19
+ const token = getAuthToken();
20
+ if (token) {
21
+ opts.headers = { ...opts.headers, Authorization: `Bearer ${token}` };
22
+ }
23
+ const res = await fetch(url, opts);
24
+ if (res.status === 401) {
25
+ setAuthToken(null);
26
+ if (onAuthFailure) onAuthFailure();
27
+ throw new Error('Unauthorized');
28
+ }
29
+ return res;
30
+ }
31
+
32
+ export async function login(password) {
33
+ const res = await fetch(`${BASE}/api/login`, {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify({ password }),
37
+ });
38
+ if (!res.ok) {
39
+ const data = await res.json().catch(() => ({}));
40
+ throw new Error(data.error || 'Login failed');
41
+ }
42
+ const data = await res.json();
43
+ if (data.token) setAuthToken(data.token);
44
+ return data;
45
+ }
46
+
3
47
  // Append optional dateFrom/dateTo (ms timestamps) to URLSearchParams
4
48
  function appendDateParams(q, params) {
5
49
  if (params.dateFrom) q.set('dateFrom', params.dateFrom);
@@ -109,3 +153,74 @@ export async function fetchToolCalls(name, opts = {}) {
109
153
  const res = await fetch(`${BASE}/api/tool-calls?${q}`);
110
154
  return res.json();
111
155
  }
156
+
157
+ // โ”€โ”€ Relay API โ”€โ”€
158
+
159
+ export async function fetchMode() {
160
+ try {
161
+ const res = await fetch(`${BASE}/api/mode`);
162
+ if (!res.ok) return { mode: 'local' };
163
+ return res.json();
164
+ } catch {
165
+ return { mode: 'local' };
166
+ }
167
+ }
168
+
169
+ export async function fetchRelayTeamStats() {
170
+ const res = await authFetch(`${BASE}/relay/team-stats`);
171
+ return res.json();
172
+ }
173
+
174
+ export async function fetchRelayUsers() {
175
+ const res = await authFetch(`${BASE}/relay/users`);
176
+ return res.json();
177
+ }
178
+
179
+ export async function fetchRelayUserActivity(username, opts = {}) {
180
+ const q = new URLSearchParams();
181
+ if (opts.folder) q.set('folder', opts.folder);
182
+ if (opts.limit) q.set('limit', opts.limit);
183
+ const qs = q.toString();
184
+ const res = await authFetch(`${BASE}/relay/activity/${encodeURIComponent(username)}${qs ? '?' + qs : ''}`);
185
+ return res.json();
186
+ }
187
+
188
+ export async function fetchRelaySearch(query, opts = {}) {
189
+ const q = new URLSearchParams({ q: query });
190
+ if (opts.username) q.set('username', opts.username);
191
+ if (opts.folder) q.set('folder', opts.folder);
192
+ if (opts.limit) q.set('limit', opts.limit);
193
+ const res = await authFetch(`${BASE}/relay/search?${q}`);
194
+ return res.json();
195
+ }
196
+
197
+ export async function fetchRelayFeed(opts = {}) {
198
+ const q = new URLSearchParams();
199
+ if (opts.limit) q.set('limit', opts.limit);
200
+ if (opts.since) q.set('since', opts.since);
201
+ const qs = q.toString();
202
+ const res = await authFetch(`${BASE}/relay/feed${qs ? '?' + qs : ''}`);
203
+ return res.json();
204
+ }
205
+
206
+ export async function fetchRelayConfig() {
207
+ const res = await authFetch(`${BASE}/relay/config`);
208
+ return res.json();
209
+ }
210
+
211
+ export async function mergeRelayUsers(from, to) {
212
+ const res = await authFetch(`${BASE}/relay/merge-users`, {
213
+ method: 'POST',
214
+ headers: { 'Content-Type': 'application/json' },
215
+ body: JSON.stringify({ from, to }),
216
+ });
217
+ return res.json();
218
+ }
219
+
220
+ export async function fetchRelaySession(chatId, username) {
221
+ const q = new URLSearchParams();
222
+ if (username) q.set('username', username);
223
+ const qs = q.toString();
224
+ const res = await authFetch(`${BASE}/relay/session/${encodeURIComponent(chatId)}${qs ? '?' + qs : ''}`);
225
+ return res.json();
226
+ }
@@ -1,156 +1,10 @@
1
1
  import { useState, useEffect } from 'react'
2
2
  import { useParams, useNavigate } from 'react-router-dom'
3
- import { ArrowLeft, User, Bot, Wrench, Settings, Play, CheckCircle, ChevronRight, ChevronDown, Download } from 'lucide-react'
4
- import ReactMarkdown from 'react-markdown'
5
- import remarkGfm from 'remark-gfm'
3
+ import { ArrowLeft, Download } from 'lucide-react'
6
4
  import { fetchChat, BASE } from '../lib/api'
7
5
  import { editorColor, editorLabel, formatDateTime, formatNumber } from '../lib/constants'
8
6
  import KpiCard from '../components/KpiCard'
9
-
10
- const ROLE_CONFIG = {
11
- user: { icon: User, label: 'User', borderColor: 'rgba(34,197,94,0.2)', bg: 'rgba(34,197,94,0.05)' },
12
- assistant: { icon: Bot, label: 'Assistant', borderColor: 'rgba(99,102,241,0.2)', bg: 'rgba(99,102,241,0.05)' },
13
- system: { icon: Settings, label: 'System', borderColor: 'rgba(107,114,128,0.2)', bg: 'rgba(107,114,128,0.05)' },
14
- tool: { icon: Wrench, label: 'Tool', borderColor: 'rgba(234,179,8,0.2)', bg: 'rgba(234,179,8,0.05)' },
15
- }
16
-
17
- // Parse message content into segments: text, tool-call, tool-result
18
- function parseContent(content) {
19
- const segments = []
20
- const regex = /\[tool-call: ([^\]]+)\]|\[tool-result: ([^\]]+)\]\s*(.*?)(?=\n\[tool-|$)/gs
21
- let lastIdx = 0
22
- let match
23
- while ((match = regex.exec(content)) !== null) {
24
- if (match.index > lastIdx) {
25
- const text = content.slice(lastIdx, match.index).trim()
26
- if (text) segments.push({ type: 'text', value: text })
27
- }
28
- if (match[1]) {
29
- segments.push({ type: 'tool-call', name: match[1].replace(/\(.*\)$/, '').trim(), args: match[1] })
30
- } else if (match[2]) {
31
- segments.push({ type: 'tool-result', name: match[2].trim(), preview: (match[3] || '').trim() })
32
- }
33
- lastIdx = match.index + match[0].length
34
- }
35
- if (lastIdx < content.length) {
36
- const text = content.slice(lastIdx).trim()
37
- if (text) segments.push({ type: 'text', value: text })
38
- }
39
- return segments.length > 0 ? segments : [{ type: 'text', value: content }]
40
- }
41
-
42
- function ToolArgsDiff({ args }) {
43
- const old = args.old_string || args.old_text || args.oldText || args.search || null
44
- const nw = args.new_string || args.new_text || args.newText || args.replace || null
45
- if (old == null && nw == null) return null
46
- const maxLines = 12
47
- const oldLines = (old || '').split('\n').slice(0, maxLines)
48
- const newLines = (nw || '').split('\n').slice(0, maxLines)
49
- return (
50
- <div className="mt-1.5 text-[9px] font-mono overflow-x-auto" style={{ border: '1px solid var(--c-border)' }}>
51
- {(args.file_path || args.TargetFile) && (
52
- <div className="px-2 py-0.5" style={{ background: 'var(--c-code-bg)', color: 'var(--c-text)' }}>{args.file_path || args.TargetFile}</div>
53
- )}
54
- {old && oldLines.map((line, i) => (
55
- <div key={'o' + i} className="px-2" style={{ background: 'rgba(248,113,113,0.1)', color: '#f87171' }}>
56
- <span style={{ color: 'var(--c-text3)', userSelect: 'none' }}>- </span>{line}
57
- </div>
58
- ))}
59
- {old && oldLines.length < (old || '').split('\n').length && (
60
- <div className="px-2" style={{ color: 'var(--c-text3)' }}> ... {(old || '').split('\n').length - maxLines} more lines</div>
61
- )}
62
- {nw && newLines.map((line, i) => (
63
- <div key={'n' + i} className="px-2" style={{ background: 'rgba(52,211,153,0.1)', color: '#34d399' }}>
64
- <span style={{ color: 'var(--c-text3)', userSelect: 'none' }}>+ </span>{line}
65
- </div>
66
- ))}
67
- {nw && newLines.length < (nw || '').split('\n').length && (
68
- <div className="px-2" style={{ color: 'var(--c-text3)' }}> ... {(nw || '').split('\n').length - maxLines} more lines</div>
69
- )}
70
- </div>
71
- )
72
- }
73
-
74
- function ToolArgsDetail({ args }) {
75
- if (!args || Object.keys(args).length === 0) return null
76
- const hasDiff = args.old_string || args.new_string || args.old_text || args.new_text || args.search || args.replace
77
- if (hasDiff) return <ToolArgsDiff args={args} />
78
- // Summarize key args
79
- const file = args.file_path || args.TargetFile || args.filePath || args.path || null
80
- const cmd = args.CommandLine || args.command || null
81
- const query = args.Query || args.query || args.search_term || null
82
- const url = args.Url || args.url || null
83
- return (
84
- <div className="mt-1.5 text-[9px] font-mono overflow-x-auto" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
85
- {file && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>file: {file}</div>}
86
- {cmd && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>cmd: {cmd}</div>}
87
- {query && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>query: {query}</div>}
88
- {url && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>url: {url}</div>}
89
- {!file && !cmd && !query && !url && (
90
- <pre className="px-2 py-1 whitespace-pre-wrap break-all" style={{ color: 'var(--c-text2)' }}>{JSON.stringify(args, null, 2)}</pre>
91
- )}
92
- </div>
93
- )
94
- }
95
-
96
- function ToolCallBlock({ name, args, detail }) {
97
- const [open, setOpen] = useState(false)
98
- const hasDetail = detail && Object.keys(detail).length > 0
99
- return (
100
- <div className="my-1 px-2.5 py-1.5 text-[10px]" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
101
- <div className="flex items-center gap-2 cursor-pointer" onClick={() => hasDetail && setOpen(!open)}>
102
- {hasDetail
103
- ? (open ? <ChevronDown size={10} style={{ color: '#a78bfa' }} /> : <ChevronRight size={10} style={{ color: '#a78bfa' }} />)
104
- : <Play size={10} style={{ color: '#a78bfa' }} />
105
- }
106
- <span className="font-bold" style={{ color: 'var(--c-white)' }}>{name}</span>
107
- {args !== name && !hasDetail && <span className="truncate" style={{ color: 'var(--c-text2)' }}>{args}</span>}
108
- {hasDetail && <span style={{ color: 'var(--c-text2)' }}>{summarizeToolArgs(name, detail)}</span>}
109
- </div>
110
- {open && hasDetail && <ToolArgsDetail args={detail} />}
111
- </div>
112
- )
113
- }
114
-
115
- function summarizeToolArgs(name, args) {
116
- if (!args || typeof args !== 'object') return ''
117
- if (args.file_path || args.TargetFile) return args.file_path || args.TargetFile
118
- if (args.CommandLine || args.command) return args.CommandLine || args.command
119
- if (args.Query || args.query) return `${args.Query || args.query}${args.SearchPath ? ` in ${args.SearchPath}` : ''}`
120
- if (args.Url || args.url) return args.Url || args.url
121
- const vals = Object.values(args).filter(v => typeof v === 'string' && v.length > 0 && v.length < 120)
122
- return vals.length > 0 ? vals[0] : ''
123
- }
124
-
125
- function ToolResultBlock({ name, preview }) {
126
- const [open, setOpen] = useState(false)
127
- const isNoisy = preview.length > 120 || preview.startsWith('{') || preview.includes('contentId')
128
- const short = isNoisy ? `${name} completed` : preview.substring(0, 120)
129
- return (
130
- <div className="my-1 px-2.5 py-1.5 text-[10px]" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
131
- <div className="flex items-center gap-2 cursor-pointer" onClick={() => preview && setOpen(!open)}>
132
- <CheckCircle size={10} style={{ color: '#34d399' }} />
133
- <span style={{ color: 'var(--c-text)' }}>{short}</span>
134
- {isNoisy && preview && <span style={{ color: 'var(--c-text3)' }}>{open ? '[-]' : '[+]'}</span>}
135
- </div>
136
- {open && <pre className="mt-1 text-[9px] overflow-x-auto whitespace-pre-wrap break-all" style={{ color: 'var(--c-text2)' }}>{preview}</pre>}
137
- </div>
138
- )
139
- }
140
-
141
- function MessageContent({ content, toolCallDetails }) {
142
- const segments = parseContent(content)
143
- let toolIdx = 0
144
- return segments.map((seg, i) => {
145
- if (seg.type === 'tool-call') {
146
- const detail = toolCallDetails ? toolCallDetails.find(tc => tc.name === seg.name && toolCallDetails.indexOf(tc) >= toolIdx) : null
147
- if (detail) toolIdx = toolCallDetails.indexOf(detail) + 1
148
- return <ToolCallBlock key={i} name={seg.name} args={seg.args} detail={detail?.args} />
149
- }
150
- if (seg.type === 'tool-result') return <ToolResultBlock key={i} name={seg.name} preview={seg.preview} />
151
- return <div key={i} className="md-body"><ReactMarkdown remarkPlugins={[remarkGfm]}>{seg.value}</ReactMarkdown></div>
152
- })
153
- }
7
+ import { MessageBubble } from '../components/MessageRenderer'
154
8
 
155
9
  export default function ChatDetail() {
156
10
  const { id } = useParams()
@@ -241,22 +95,9 @@ export default function ChatDetail() {
241
95
  {chat.encrypted ? '๐Ÿ”’ This conversation is encrypted.' : 'No messages found.'}
242
96
  </div>
243
97
  )}
244
- {chat.messages.map((msg, i) => {
245
- const cfg = ROLE_CONFIG[msg.role] || ROLE_CONFIG.system
246
- const Icon = cfg.icon
247
- return (
248
- <div key={i} className="rounded-r-lg px-4 py-3" style={{ borderLeft: `2px solid ${cfg.borderColor}`, background: cfg.bg }}>
249
- <div className="flex items-center gap-2 text-xs mb-2" style={{ color: 'var(--c-text2)' }}>
250
- <Icon size={13} />
251
- <span className="font-medium">{cfg.label}</span>
252
- {msg.model && <span className="font-mono" style={{ color: 'var(--c-accent)', opacity: 0.6 }}>ยท {msg.model}</span>}
253
- </div>
254
- <div className="text-sm" style={{ color: 'var(--c-text)' }}>
255
- <MessageContent content={msg.content} toolCallDetails={chat.toolCallDetails} />
256
- </div>
257
- </div>
258
- )
259
- })}
98
+ {chat.messages.map((msg, i) => (
99
+ <MessageBubble key={i} msg={msg} toolCallDetails={chat.toolCallDetails} />
100
+ ))}
260
101
  </div>
261
102
  </div>
262
103
  )
@@ -9,6 +9,7 @@ import DateRangePicker from '../components/DateRangePicker'
9
9
  import { editorColor, editorLabel, formatNumber, dateRangeToApiParams } from '../lib/constants'
10
10
  import { fetchDailyActivity, fetchOverview as fetchOverviewApi, fetchDashboardStats, fetchShareImage } from '../lib/api'
11
11
  import { useTheme } from '../lib/theme'
12
+ import SectionTitle from '../components/SectionTitle'
12
13
 
13
14
  ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement, Filler)
14
15
 
@@ -20,10 +21,6 @@ const MODE_COLORS = {
20
21
  copilot: '#f59e0b', thread: '#ec4899', opencode: '#f43f5e', claude: '#f97316',
21
22
  }
22
23
 
23
- function SectionTitle({ children }) {
24
- return <h3 className="text-[10px] font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--c-text2)' }}>{children}</h3>
25
- }
26
-
27
24
  export default function Dashboard({ overview }) {
28
25
  const navigate = useNavigate()
29
26
  const [dailyData, setDailyData] = useState(null)