agentlytics 0.1.9 → 0.1.11

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,273 @@
1
+ import { useState, useEffect, useRef, useCallback } from 'react'
2
+ import { X, Download, Share2, BarChart3, DollarSign, Clock, Cpu, Braces, User, Sun, Moon } from 'lucide-react'
3
+ import { fetchShareImage } from '../lib/api'
4
+
5
+ const TOGGLE_ITEMS = [
6
+ { key: 'showEditors', label: 'Editors', icon: BarChart3 },
7
+ { key: 'showCosts', label: 'Est. Costs', icon: DollarSign },
8
+ { key: 'showHours', label: 'Peak Hours', icon: Clock },
9
+ { key: 'showModels', label: 'Top Models', icon: Cpu },
10
+ { key: 'showTokens', label: 'Token Footer', icon: Braces },
11
+ ]
12
+
13
+ export default function ShareModal({ open, onClose }) {
14
+ const [opts, setOpts] = useState({
15
+ showEditors: true,
16
+ showCosts: true,
17
+ showHours: true,
18
+ showModels: true,
19
+ showTokens: true,
20
+ username: '',
21
+ theme: 'dark',
22
+ })
23
+ const [svg, setSvg] = useState('')
24
+ const [loading, setLoading] = useState(false)
25
+ const [downloading, setDownloading] = useState(false)
26
+ const debounceRef = useRef(null)
27
+ const backdropRef = useRef(null)
28
+
29
+ const loadPreview = useCallback(async (currentOpts) => {
30
+ setLoading(true)
31
+ try {
32
+ const result = await fetchShareImage(currentOpts)
33
+ if (result && !result.startsWith('{')) setSvg(result)
34
+ } catch (e) {
35
+ console.error('Preview failed:', e)
36
+ }
37
+ setLoading(false)
38
+ }, [])
39
+
40
+ useEffect(() => {
41
+ if (!open) return
42
+ loadPreview(opts)
43
+ }, [open])
44
+
45
+ const updateOpt = (key, value) => {
46
+ const next = { ...opts, [key]: value }
47
+ setOpts(next)
48
+ if (debounceRef.current) clearTimeout(debounceRef.current)
49
+ debounceRef.current = setTimeout(() => loadPreview(next), 300)
50
+ }
51
+
52
+ const handleDownloadPng = async () => {
53
+ if (!svg) return
54
+ setDownloading(true)
55
+ try {
56
+ const canvas = document.createElement('canvas')
57
+ canvas.width = 1200
58
+ canvas.height = 675
59
+ const ctx = canvas.getContext('2d')
60
+ const img = new Image()
61
+ const svgB64 = btoa(unescape(encodeURIComponent(svg)))
62
+ const dataUrl = `data:image/svg+xml;base64,${svgB64}`
63
+ await new Promise((resolve, reject) => {
64
+ img.onload = resolve
65
+ img.onerror = reject
66
+ img.src = dataUrl
67
+ })
68
+ ctx.drawImage(img, 0, 0, 1200, 675)
69
+ const pngUrl = canvas.toDataURL('image/png')
70
+ const a = document.createElement('a')
71
+ a.href = pngUrl
72
+ a.download = 'agentlytics.png'
73
+ a.click()
74
+ } catch {
75
+ const blob = new Blob([svg], { type: 'image/svg+xml' })
76
+ const a = document.createElement('a')
77
+ a.href = URL.createObjectURL(blob)
78
+ a.download = 'agentlytics.svg'
79
+ a.click()
80
+ URL.revokeObjectURL(a.href)
81
+ }
82
+ setDownloading(false)
83
+ }
84
+
85
+ const handleDownloadSvg = () => {
86
+ if (!svg) return
87
+ const blob = new Blob([svg], { type: 'image/svg+xml' })
88
+ const a = document.createElement('a')
89
+ a.href = URL.createObjectURL(blob)
90
+ a.download = 'agentlytics.svg'
91
+ a.click()
92
+ URL.revokeObjectURL(a.href)
93
+ }
94
+
95
+ const handleShareTwitter = async () => {
96
+ await handleDownloadPng()
97
+ const text = encodeURIComponent("Here's my agentic coding stats using github.com/f/agentlytics")
98
+ window.open(`https://x.com/intent/post?text=${text}`, '_blank')
99
+ }
100
+
101
+ if (!open) return null
102
+
103
+ return (
104
+ <div
105
+ ref={backdropRef}
106
+ className="fixed inset-0 z-50 flex items-center justify-center p-4"
107
+ style={{ background: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(8px)' }}
108
+ onClick={(e) => e.target === backdropRef.current && onClose()}
109
+ >
110
+ <div
111
+ className="w-full relative flex flex-col"
112
+ style={{
113
+ maxWidth: 960,
114
+ maxHeight: '90vh',
115
+ background: 'var(--c-bg)',
116
+ border: '1px solid var(--c-border)',
117
+ borderRadius: 12,
118
+ }}
119
+ >
120
+ {/* Header */}
121
+ <div className="flex items-center justify-between px-5 py-3" style={{ borderBottom: '1px solid var(--c-border)' }}>
122
+ <div className="flex items-center gap-2">
123
+ <Share2 size={14} style={{ color: 'var(--c-accent)' }} />
124
+ <span className="text-[13px] font-semibold" style={{ color: 'var(--c-white)' }}>Share Stats</span>
125
+ </div>
126
+ <button onClick={onClose} className="p-1 rounded hover:opacity-70 transition" style={{ color: 'var(--c-text2)' }}>
127
+ <X size={16} />
128
+ </button>
129
+ </div>
130
+
131
+ {/* Body */}
132
+ <div className="flex-1 overflow-auto p-5" style={{ minHeight: 0 }}>
133
+ <div className="flex gap-5" style={{ flexDirection: 'row' }}>
134
+
135
+ {/* Sidebar: toggles */}
136
+ <div className="flex-shrink-0" style={{ width: 200 }}>
137
+ <div className="text-[11px] font-medium mb-3" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
138
+ Customize
139
+ </div>
140
+
141
+ <div className="space-y-1.5">
142
+ {TOGGLE_ITEMS.map(({ key, label, icon: Icon }) => {
143
+ const active = opts[key]
144
+ return (
145
+ <button
146
+ key={key}
147
+ onClick={() => updateOpt(key, !active)}
148
+ className="flex items-center gap-2 w-full px-2.5 py-2 rounded-md text-[12px] transition"
149
+ style={{
150
+ background: active ? 'var(--c-accent-bg, rgba(99,102,241,0.1))' : 'transparent',
151
+ border: `1px solid ${active ? 'var(--c-accent, #6366f1)' : 'var(--c-border)'}`,
152
+ color: active ? 'var(--c-accent, #818cf8)' : 'var(--c-text2)',
153
+ opacity: active ? 1 : 0.6,
154
+ }}
155
+ >
156
+ <Icon size={12} />
157
+ {label}
158
+ </button>
159
+ )
160
+ })}
161
+ </div>
162
+
163
+ <div className="mt-4">
164
+ <div className="text-[11px] font-medium mb-2" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
165
+ Theme
166
+ </div>
167
+ <div className="flex gap-1.5">
168
+ {[{ key: 'dark', icon: Moon, label: 'Dark' }, { key: 'light', icon: Sun, label: 'Light' }].map(({ key, icon: Icon, label }) => {
169
+ const active = opts.theme === key
170
+ return (
171
+ <button
172
+ key={key}
173
+ onClick={() => updateOpt('theme', key)}
174
+ className="flex items-center gap-1.5 flex-1 justify-center px-2 py-1.5 rounded-md text-[11px] transition"
175
+ style={{
176
+ background: active ? 'var(--c-accent-bg, rgba(99,102,241,0.1))' : 'transparent',
177
+ border: `1px solid ${active ? 'var(--c-accent, #6366f1)' : 'var(--c-border)'}`,
178
+ color: active ? 'var(--c-accent, #818cf8)' : 'var(--c-text2)',
179
+ opacity: active ? 1 : 0.6,
180
+ }}
181
+ >
182
+ <Icon size={11} />
183
+ {label}
184
+ </button>
185
+ )
186
+ })}
187
+ </div>
188
+ </div>
189
+
190
+ <div className="mt-4">
191
+ <div className="text-[11px] font-medium mb-2" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
192
+ Username
193
+ </div>
194
+ <div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md" style={{ border: '1px solid var(--c-border)', background: 'var(--c-bg)' }}>
195
+ <User size={11} style={{ color: 'var(--c-text3)' }} />
196
+ <input
197
+ type="text"
198
+ placeholder="optional"
199
+ value={opts.username}
200
+ onChange={(e) => updateOpt('username', e.target.value)}
201
+ className="bg-transparent outline-none text-[12px] w-full"
202
+ style={{ color: 'var(--c-text)' }}
203
+ />
204
+ </div>
205
+ </div>
206
+ </div>
207
+
208
+ {/* Preview */}
209
+ <div className="flex-1 min-w-0">
210
+ <div className="text-[11px] font-medium mb-3" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
211
+ Preview
212
+ </div>
213
+ <div
214
+ className="rounded-lg overflow-hidden relative"
215
+ style={{
216
+ border: '1px solid var(--c-border)',
217
+ background: '#09090f',
218
+ minHeight: 200,
219
+ }}
220
+ >
221
+ {loading && (
222
+ <div className="absolute inset-0 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.5)', zIndex: 2 }}>
223
+ <span className="text-[12px]" style={{ color: 'var(--c-text2)' }}>Generating...</span>
224
+ </div>
225
+ )}
226
+ {svg && (
227
+ <div
228
+ className="w-full [&>svg]:w-full [&>svg]:h-auto [&>svg]:block"
229
+ dangerouslySetInnerHTML={{ __html: svg }}
230
+ />
231
+ )}
232
+ </div>
233
+ </div>
234
+ </div>
235
+ </div>
236
+
237
+ {/* Footer: actions */}
238
+ <div className="flex items-center justify-between px-5 py-3" style={{ borderTop: '1px solid var(--c-border)' }}>
239
+ <span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>
240
+ Tip: Toggle sections to customize your share card
241
+ </span>
242
+ <div className="flex items-center gap-2">
243
+ <button
244
+ onClick={handleDownloadSvg}
245
+ className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
246
+ style={{ border: '1px solid var(--c-border)', color: 'var(--c-text)' }}
247
+ >
248
+ <Download size={12} />
249
+ SVG
250
+ </button>
251
+ <button
252
+ onClick={handleDownloadPng}
253
+ disabled={downloading}
254
+ className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
255
+ style={{ border: '1px solid var(--c-border)', color: 'var(--c-text)', opacity: downloading ? 0.5 : 1 }}
256
+ >
257
+ <Download size={12} />
258
+ {downloading ? 'Converting...' : 'PNG'}
259
+ </button>
260
+ <button
261
+ onClick={handleShareTwitter}
262
+ className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
263
+ style={{ background: '#6366f1', color: '#fff' }}
264
+ >
265
+ <Share2 size={12} />
266
+ Share on X
267
+ </button>
268
+ </div>
269
+ </div>
270
+ </div>
271
+ </div>
272
+ )
273
+ }
package/ui/src/lib/api.js CHANGED
@@ -180,8 +180,17 @@ export async function fetchSchema() {
180
180
  return res.json();
181
181
  }
182
182
 
183
- export async function fetchShareImage() {
184
- const res = await fetch(`${BASE}/api/share-image`);
183
+ export async function fetchShareImage(opts = {}) {
184
+ const q = new URLSearchParams();
185
+ if (opts.showEditors !== undefined) q.set('showEditors', opts.showEditors);
186
+ if (opts.showModels !== undefined) q.set('showModels', opts.showModels);
187
+ if (opts.showCosts !== undefined) q.set('showCosts', opts.showCosts);
188
+ if (opts.showTokens !== undefined) q.set('showTokens', opts.showTokens);
189
+ if (opts.showHours !== undefined) q.set('showHours', opts.showHours);
190
+ if (opts.username) q.set('username', opts.username);
191
+ if (opts.theme) q.set('theme', opts.theme);
192
+ const qs = q.toString();
193
+ const res = await fetch(`${BASE}/api/share-image${qs ? '?' + qs : ''}`);
185
194
  return res.text();
186
195
  }
187
196
 
@@ -210,11 +219,6 @@ export async function fetchRelayTeamStats() {
210
219
  return res.json();
211
220
  }
212
221
 
213
- export async function fetchRelayUsers() {
214
- const res = await authFetch(`${BASE}/relay/users`);
215
- return res.json();
216
- }
217
-
218
222
  export async function fetchRelayUserActivity(username, opts = {}) {
219
223
  const q = new URLSearchParams();
220
224
  if (opts.folder) q.set('folder', opts.folder);
@@ -8,8 +8,9 @@ import ActivityHeatmap from '../components/ActivityHeatmap'
8
8
  import DateRangePicker from '../components/DateRangePicker'
9
9
  import { editorColor, editorLabel, formatNumber, formatCost, dateRangeToApiParams } from '../lib/constants'
10
10
  import EditorIcon from '../components/EditorIcon'
11
- import { fetchDailyActivity, fetchOverview as fetchOverviewApi, fetchDashboardStats, fetchShareImage, fetchChats, fetchCosts } from '../lib/api'
11
+ import { fetchDailyActivity, fetchOverview as fetchOverviewApi, fetchDashboardStats, fetchChats, fetchCosts } from '../lib/api'
12
12
  import ChatSidebar from '../components/ChatSidebar'
13
+ import ShareModal from '../components/ShareModal'
13
14
  import { useTheme } from '../lib/theme'
14
15
  import SectionTitle from '../components/SectionTitle'
15
16
 
@@ -32,7 +33,7 @@ export default function Dashboard({ overview }) {
32
33
  const [dateRange, setDateRange] = useState(null)
33
34
  const { dark } = useTheme()
34
35
  const [costs, setCosts] = useState(null)
35
- const [sharing, setSharing] = useState(false)
36
+ const [shareOpen, setShareOpen] = useState(false)
36
37
  const [largeContextChats, setLargeContextChats] = useState(null)
37
38
  const [selectedChatId, setSelectedChatId] = useState(null)
38
39
  const txtColor = dark ? '#888' : '#555'
@@ -169,54 +170,6 @@ export default function Dashboard({ overview }) {
169
170
  return s + v * midpoints[i]
170
171
  }, 0) / tk.sessions).toFixed(1) : '—') : '—'
171
172
 
172
- const handleShare = async () => {
173
- setSharing(true)
174
- try {
175
- const svg = await fetchShareImage()
176
- if (!svg || svg.startsWith('{')) throw new Error('Failed to fetch image')
177
-
178
- // Try PNG conversion via canvas, fallback to SVG download
179
- let downloaded = false
180
- try {
181
- const canvas = document.createElement('canvas')
182
- canvas.width = 1600
183
- canvas.height = 880
184
- const ctx = canvas.getContext('2d')
185
- const img = new Image()
186
- const svgB64 = btoa(unescape(encodeURIComponent(svg)))
187
- const dataUrl = `data:image/svg+xml;base64,${svgB64}`
188
- await new Promise((resolve, reject) => {
189
- img.onload = resolve
190
- img.onerror = reject
191
- img.src = dataUrl
192
- })
193
- ctx.drawImage(img, 0, 0, 1600, 880)
194
- const pngUrl = canvas.toDataURL('image/png')
195
- const a = document.createElement('a')
196
- a.href = pngUrl
197
- a.download = 'agentlytics.png'
198
- a.click()
199
- downloaded = true
200
- } catch {
201
- // Fallback: download SVG directly
202
- const blob = new Blob([svg], { type: 'image/svg+xml' })
203
- const a = document.createElement('a')
204
- a.href = URL.createObjectURL(blob)
205
- a.download = 'agentlytics.svg'
206
- a.click()
207
- URL.revokeObjectURL(a.href)
208
- downloaded = true
209
- }
210
-
211
- if (downloaded) {
212
- const text = encodeURIComponent("Here's my agentic coding stats using github.com/f/agentlytics")
213
- window.open(`https://x.com/intent/post?text=${text}`, '_blank')
214
- }
215
- } catch (e) {
216
- console.error('Share failed:', e)
217
- }
218
- setSharing(false)
219
- }
220
173
 
221
174
  return (
222
175
  <div className="fade-in space-y-3">
@@ -224,13 +177,12 @@ export default function Dashboard({ overview }) {
224
177
  <div className="flex items-center justify-end gap-3">
225
178
  <DateRangePicker value={dateRange} onChange={setDateRange} />
226
179
  <button
227
- onClick={handleShare}
228
- disabled={sharing}
180
+ onClick={() => setShareOpen(true)}
229
181
  className="flex items-center gap-1.5 px-3 py-1 text-[12px] rounded-md transition hover:opacity-80"
230
- style={{ background: '#6366f1', color: '#fff', opacity: sharing ? 0.5 : 1 }}
182
+ style={{ background: '#6366f1', color: '#fff' }}
231
183
  >
232
184
  <Share2 size={12} />
233
- {sharing ? 'Generating...' : 'Share Stats'}
185
+ Share Stats
234
186
  </button>
235
187
  </div>
236
188
 
@@ -561,6 +513,7 @@ export default function Dashboard({ overview }) {
561
513
  )}
562
514
  </div>
563
515
  <ChatSidebar chatId={selectedChatId} onClose={() => setSelectedChatId(null)} />
516
+ <ShareModal open={shareOpen} onClose={() => setShareOpen(false)} />
564
517
  </div>
565
518
  )
566
519
  }
@@ -1,9 +1,9 @@
1
1
  import { useState, useEffect, useRef, useMemo } from 'react'
2
2
  import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement } from 'chart.js'
3
3
  import { Doughnut, Bar } from 'react-chartjs-2'
4
- import { Loader2, X, ArrowRight, Zap, MessageSquare, Wrench, Cpu, TrendingUp, BarChart3 } from 'lucide-react'
4
+ import { Loader2, X, Zap, MessageSquare, Wrench, Cpu, TrendingUp } from 'lucide-react'
5
5
  import { fetchDeepAnalytics, fetchToolCalls, fetchCosts } from '../lib/api'
6
- import { editorLabel, editorColor, formatNumber, formatCost, formatDateTime, dateRangeToApiParams } from '../lib/constants'
6
+ import { editorLabel, editorColor, formatNumber, formatCost, dateRangeToApiParams } from '../lib/constants'
7
7
  import { useTheme } from '../lib/theme'
8
8
  import KpiCard from '../components/KpiCard'
9
9
  import EditorIcon from '../components/EditorIcon'
@@ -80,7 +80,6 @@ export default function ProjectDetail() {
80
80
  if (!project) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text3)' }}>project not found</div>
81
81
 
82
82
  const editorEntries = Object.entries(project.editors).sort((a, b) => b[1] - a[1])
83
- const maxEditorCount = editorEntries.length > 0 ? editorEntries[0][1] : 1
84
83
  const allEnabled = !enabledEditors || enabledEditors.size === editorEntries.length
85
84
 
86
85
  // Derive stats from editor-filtered chats
@@ -1,7 +1,7 @@
1
1
  import { useState, useEffect } from 'react'
2
2
  import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement } from 'chart.js'
3
3
  import { Doughnut, Bar } from 'react-chartjs-2'
4
- import { Search, MessageSquare, Wrench, Cpu, FolderOpen, Calendar, ArrowRight } from 'lucide-react'
4
+ import { Search, MessageSquare, Wrench, Cpu, FolderOpen, Calendar } from 'lucide-react'
5
5
  import { useNavigate } from 'react-router-dom'
6
6
  import { fetchProjects, fetchCosts } from '../lib/api'
7
7
  import { editorColor, editorLabel, formatNumber, formatCost, formatDate, dateRangeToApiParams } from '../lib/constants'
@@ -1,6 +1,6 @@
1
1
  import { useState, useEffect, useMemo } from 'react'
2
2
  import { useNavigate } from 'react-router-dom'
3
- import { Users, Cpu, ArrowRight, Search, Merge, MessageSquare, Zap, FolderOpen, ChevronDown, ChevronRight, User } from 'lucide-react'
3
+ import { Users, Search, Merge, MessageSquare, FolderOpen, ChevronDown, ChevronRight } 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'
@@ -36,7 +36,7 @@ function ProportionBar({ segments, height = 6 }) {
36
36
  }
37
37
 
38
38
  // Left sidebar: team members grouped by project (folder-tree view)
39
- function TeamSidebar({ userList, userColorMap, onUserClick, selectedUser }) {
39
+ function TeamSidebar({ userList, userColorMap, selectedUser }) {
40
40
  const [collapsed, setCollapsed] = useState(new Set())
41
41
  const navigate = useNavigate()
42
42
 
@@ -1,6 +1,6 @@
1
1
  import { useState, useEffect, useCallback, useMemo } from 'react'
2
2
  import { useParams, useNavigate } from 'react-router-dom'
3
- import { ArrowLeft, MessageSquare, FolderOpen, ChevronDown, ChevronRight, Zap, Clock, Hash } from 'lucide-react'
3
+ import { ArrowLeft, MessageSquare, FolderOpen, ChevronDown, ChevronRight, Hash } 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'
@@ -37,7 +37,6 @@ function ProportionBar({ segments, height = 6 }) {
37
37
  // Left sidebar: sessions grouped by project (folder-tree)
38
38
  function SessionSidebar({ sessions, projects, selectedChat, onSelectChat }) {
39
39
  const [collapsed, setCollapsed] = useState(new Set())
40
- const [filter, setFilter] = useState(null) // null = all, or project path
41
40
 
42
41
  const toggle = (key) => {
43
42
  setCollapsed(prev => {
@@ -142,7 +141,6 @@ export default function RelayUserDetail() {
142
141
  const legendColor = dark ? '#888' : '#555'
143
142
  const gridColor = dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.06)'
144
143
  const txtDim = dark ? '#555' : '#999'
145
- const txtColor = dark ? '#888' : '#555'
146
144
 
147
145
  useEffect(() => {
148
146
  if (username) {
@@ -1,22 +0,0 @@
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-[11px] 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-[11px] w-10 text-right" style={{ color: 'var(--c-text2)' }}>{count}</span>
17
- </div>
18
- )
19
- })}
20
- </div>
21
- )
22
- }
@@ -1,23 +0,0 @@
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-[11px] 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-[11px] w-10 text-right" style={{ color: 'var(--c-text2)' }}>{count}</span>
17
- </div>
18
- )
19
- })}
20
- {models.length === 0 && <div className="text-[11px]" style={{ color: 'var(--c-text3)' }}>No model data</div>}
21
- </div>
22
- )
23
- }
@@ -1,107 +0,0 @@
1
- import { useState, useEffect } from 'react'
2
- import { useParams, useNavigate } from 'react-router-dom'
3
- import { ArrowLeft, Download } from 'lucide-react'
4
- import { fetchChat, fetchCosts, BASE } from '../lib/api'
5
- import { editorColor, editorLabel, formatDateTime, formatNumber, formatCost } from '../lib/constants'
6
- import KpiCard from '../components/KpiCard'
7
- import { MessageBubble } from '../components/MessageRenderer'
8
-
9
- export default function ChatDetail() {
10
- const { id } = useParams()
11
- const navigate = useNavigate()
12
- const [chat, setChat] = useState(null)
13
- const [costs, setCosts] = useState(null)
14
- const [loading, setLoading] = useState(true)
15
-
16
- useEffect(() => {
17
- setLoading(true)
18
- fetchChat(id).then(data => {
19
- setChat(data)
20
- fetchCosts({ chatId: data.id }).then(setCosts)
21
- setLoading(false)
22
- })
23
- }, [id])
24
-
25
- if (loading) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>Loading conversation...</div>
26
- if (!chat) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>Chat not found.</div>
27
-
28
- const s = chat.stats
29
-
30
- return (
31
- <div className="fade-in max-w-4xl mx-auto">
32
- <button
33
- onClick={() => navigate(-1)}
34
- className="flex items-center gap-2 text-sm mb-4 transition"
35
- style={{ color: 'var(--c-text2)' }}
36
- >
37
- <ArrowLeft size={16} /> Back
38
- </button>
39
-
40
- {/* Header */}
41
- <div className="card p-5 mb-4">
42
- <div className="flex items-start justify-between">
43
- <div className="flex-1 min-w-0">
44
- <h2 className="text-xl font-semibold mb-1" style={{ color: 'var(--c-white)' }}>{chat.name || '(untitled)'}</h2>
45
- <div className="flex items-center gap-3 text-xs" style={{ color: 'var(--c-text2)' }}>
46
- <span className="inline-flex items-center gap-1.5">
47
- <span className="w-2 h-2 rounded-full" style={{ background: editorColor(chat.source) }} />
48
- {editorLabel(chat.source)}
49
- </span>
50
- {chat.mode && <span>· {chat.mode}</span>}
51
- {chat.folder && <span className="font-mono">· {chat.folder}</span>}
52
- </div>
53
- <div className="text-xs mt-1" style={{ color: 'var(--c-text3)' }}>
54
- {formatDateTime(chat.createdAt)}
55
- {chat.lastUpdatedAt && chat.lastUpdatedAt !== chat.createdAt && ` — ${formatDateTime(chat.lastUpdatedAt)}`}
56
- <span className="ml-3 font-mono" style={{ color: 'var(--c-text3)' }}>{chat.id}</span>
57
- </div>
58
- </div>
59
- <a
60
- href={`${BASE}/api/chats/${chat.id}/markdown`}
61
- download
62
- className="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition shrink-0"
63
- style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
64
- onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg2)'}
65
- onMouseLeave={e => e.currentTarget.style.background = 'var(--c-bg3)'}
66
- >
67
- <Download size={13} /> Export .md
68
- </a>
69
- </div>
70
- </div>
71
-
72
- {/* Stats */}
73
- {s && (
74
- <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3 mb-4">
75
- <KpiCard label="Messages" value={s.totalMessages} />
76
- <KpiCard label="User" value={s.userMessages} />
77
- <KpiCard label="Assistant" value={s.assistantMessages} />
78
- <KpiCard label="Tool Calls" value={s.toolCalls.length} />
79
- {s.totalInputTokens > 0 && <KpiCard label="Input Tokens" value={formatNumber(s.totalInputTokens)} />}
80
- {s.totalOutputTokens > 0 && <KpiCard label="Output Tokens" value={formatNumber(s.totalOutputTokens)} />}
81
- {costs && costs.totalCost > 0 && <KpiCard label="Est. Cost" value={formatCost(costs.totalCost)} />}
82
- </div>
83
- )}
84
-
85
- {/* Model badges */}
86
- {s && s.models.length > 0 && (
87
- <div className="flex flex-wrap gap-2 mb-4">
88
- {[...new Set(s.models)].map(m => (
89
- <span key={m} className="text-xs bg-accent/10 text-accent-light px-2.5 py-1 rounded-full font-mono">{m}</span>
90
- ))}
91
- </div>
92
- )}
93
-
94
- {/* Messages */}
95
- <div className="space-y-3">
96
- {chat.messages.length === 0 && (
97
- <div className="text-center py-12 text-sm" style={{ color: 'var(--c-text2)' }}>
98
- {chat.encrypted ? '🔒 This conversation is encrypted.' : 'No messages found.'}
99
- </div>
100
- )}
101
- {chat.messages.map((msg, i) => (
102
- <MessageBubble key={i} msg={msg} toolCallDetails={chat.toolCallDetails} />
103
- ))}
104
- </div>
105
- </div>
106
- )
107
- }
@@ -1,32 +0,0 @@
1
- import { useCallback } from 'react'
2
- import { useParams, useSearchParams, useNavigate } from 'react-router-dom'
3
- import { fetchRelaySession } from '../lib/api'
4
- import ChatSidebar from '../components/ChatSidebar'
5
-
6
- export default function RelaySessionDetail() {
7
- const { chatId } = useParams()
8
- const [searchParams] = useSearchParams()
9
- const username = searchParams.get('username')
10
- const navigate = useNavigate()
11
-
12
- const fetchFn = useCallback(
13
- (id) => fetchRelaySession(id, username),
14
- [username]
15
- )
16
-
17
- const extraHeader = username ? (
18
- <span className="text-[11px] font-medium px-1.5 py-0.5 shrink-0" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>
19
- {username}
20
- </span>
21
- ) : null
22
-
23
- return (
24
- <ChatSidebar
25
- chatId={chatId}
26
- onClose={() => navigate(-1)}
27
- fetchFn={fetchFn}
28
- username={username}
29
- extraHeader={extraHeader}
30
- />
31
- )
32
- }