agentlytics 0.0.8 → 0.0.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,236 @@
1
+ import { useState, useEffect, useMemo } from 'react'
2
+ import { useSearchParams, useNavigate } from 'react-router-dom'
3
+ import { ArrowLeft, Search } from 'lucide-react'
4
+ import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement } from 'chart.js'
5
+ import { Doughnut, Bar } from 'react-chartjs-2'
6
+ import { fetchProjects, fetchChats } from '../lib/api'
7
+ import { editorColor, editorLabel, formatNumber, formatDate } from '../lib/constants'
8
+ import { useTheme } from '../lib/theme'
9
+ import KpiCard from '../components/KpiCard'
10
+ import EditorDot from '../components/EditorDot'
11
+ import ChatSidebar from '../components/ChatSidebar'
12
+
13
+ ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement)
14
+
15
+ const MONO = 'JetBrains Mono, monospace'
16
+ const MODEL_COLORS = ['#6366f1', '#a78bfa', '#818cf8', '#c084fc', '#e879f9', '#f472b6', '#fb7185', '#f87171', '#fbbf24', '#34d399']
17
+
18
+ export default function ProjectDetail() {
19
+ const [searchParams] = useSearchParams()
20
+ const navigate = useNavigate()
21
+ const folder = searchParams.get('folder')
22
+ const { dark } = useTheme()
23
+ const txtColor = dark ? '#888' : '#555'
24
+ const txtDim = dark ? '#555' : '#999'
25
+ const gridColor = dark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.06)'
26
+
27
+ const [project, setProject] = useState(null)
28
+ const [chats, setChats] = useState([])
29
+ const [loading, setLoading] = useState(true)
30
+ const [chatSearch, setChatSearch] = useState('')
31
+ const [selectedChatId, setSelectedChatId] = useState(null)
32
+
33
+ useEffect(() => {
34
+ if (!folder) return
35
+ setLoading(true)
36
+ Promise.all([
37
+ fetchProjects(),
38
+ fetchChats({ folder, limit: 1000 }),
39
+ ]).then(([projects, chatData]) => {
40
+ const match = projects.find(p => p.folder === folder)
41
+ setProject(match || null)
42
+ setChats(chatData.chats || [])
43
+ setLoading(false)
44
+ })
45
+ }, [folder])
46
+
47
+ const filteredChats = useMemo(() => {
48
+ if (!chatSearch) return chats
49
+ const q = chatSearch.toLowerCase()
50
+ return chats.filter(c =>
51
+ (c.name && c.name.toLowerCase().includes(q)) ||
52
+ (c.topModel && c.topModel.toLowerCase().includes(q)) ||
53
+ (c.source && c.source.toLowerCase().includes(q))
54
+ )
55
+ }, [chats, chatSearch])
56
+
57
+ if (!folder) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text3)' }}>no project specified</div>
58
+ if (loading) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading project...</div>
59
+ if (!project) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text3)' }}>project not found</div>
60
+
61
+ const editorEntries = Object.entries(project.editors).sort((a, b) => b[1] - a[1])
62
+ const maxEditorCount = editorEntries.length > 0 ? editorEntries[0][1] : 1
63
+
64
+ return (
65
+ <div className="fade-in space-y-5">
66
+ {/* Back + title */}
67
+ <div className="flex items-center gap-3">
68
+ <button
69
+ onClick={() => navigate('/projects')}
70
+ className="flex items-center gap-1.5 text-xs transition"
71
+ style={{ color: 'var(--c-text2)' }}
72
+ >
73
+ <ArrowLeft size={14} /> Projects
74
+ </button>
75
+ <div className="flex-1 min-w-0">
76
+ <h1 className="text-base font-semibold truncate" style={{ color: 'var(--c-white)' }}>{project.name}</h1>
77
+ <div className="text-[10px] truncate" style={{ color: 'var(--c-text3)' }}>{project.folder}</div>
78
+ </div>
79
+ </div>
80
+
81
+ {/* KPIs */}
82
+ <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
83
+ <KpiCard label="sessions" value={project.totalSessions} />
84
+ <KpiCard label="messages" value={formatNumber(project.totalMessages)} />
85
+ <KpiCard label="tool calls" value={formatNumber(project.totalToolCalls)} />
86
+ <KpiCard label="input tokens" value={formatNumber(project.totalInputTokens)} />
87
+ <KpiCard label="output tokens" value={formatNumber(project.totalOutputTokens)} />
88
+ <KpiCard label="active since" value={formatDate(project.firstSeen)} />
89
+ </div>
90
+
91
+ {/* Charts row */}
92
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
93
+ {/* Editors */}
94
+ <div className="card p-4">
95
+ <h3 className="text-[10px] uppercase tracking-wider mb-3" style={{ color: 'var(--c-text2)' }}>editors</h3>
96
+ <div className="space-y-2">
97
+ {editorEntries.map(([e, c]) => (
98
+ <div key={e} className="flex items-center gap-2">
99
+ <span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: editorColor(e) }} />
100
+ <span className="text-xs flex-1 truncate" style={{ color: 'var(--c-text2)' }}>{editorLabel(e)}</span>
101
+ <div className="w-20 h-3 overflow-hidden" style={{ background: 'var(--c-code-bg)' }}>
102
+ <div className="h-full" style={{ width: `${(c / maxEditorCount * 100).toFixed(0)}%`, background: editorColor(e) + '60' }} />
103
+ </div>
104
+ <span className="text-[10px] w-6 text-right" style={{ color: 'var(--c-text3)' }}>{c}</span>
105
+ </div>
106
+ ))}
107
+ </div>
108
+ </div>
109
+
110
+ {/* Models chart */}
111
+ <div className="card p-4">
112
+ <h3 className="text-[10px] uppercase tracking-wider mb-3" style={{ color: 'var(--c-text2)' }}>models</h3>
113
+ {project.topModels.length > 0 ? (
114
+ <div style={{ height: 180 }}>
115
+ <Doughnut
116
+ data={{
117
+ labels: project.topModels.map(m => m.name),
118
+ datasets: [{ data: project.topModels.map(m => m.count), backgroundColor: MODEL_COLORS, borderWidth: 0 }],
119
+ }}
120
+ options={{
121
+ responsive: true, maintainAspectRatio: false, cutout: '55%',
122
+ plugins: {
123
+ legend: { position: 'right', labels: { color: txtColor, font: { size: 8, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
124
+ tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
125
+ },
126
+ }}
127
+ />
128
+ </div>
129
+ ) : <div className="text-[10px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no model data</div>}
130
+ </div>
131
+
132
+ {/* Top tools */}
133
+ <div className="card p-4">
134
+ <h3 className="text-[10px] uppercase tracking-wider mb-3" style={{ color: 'var(--c-text2)' }}>top tools</h3>
135
+ {project.topTools.length > 0 ? (
136
+ <div style={{ height: 180 }}>
137
+ <Bar
138
+ data={{
139
+ labels: project.topTools.map(t => t.name),
140
+ datasets: [{
141
+ data: project.topTools.map(t => t.count),
142
+ backgroundColor: 'rgba(99,102,241,0.4)',
143
+ borderRadius: 2,
144
+ }],
145
+ }}
146
+ options={{
147
+ responsive: true, maintainAspectRatio: false, indexAxis: 'y',
148
+ scales: {
149
+ x: { grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 8, family: MONO } } },
150
+ y: { grid: { display: false }, ticks: { color: txtColor, font: { size: 8, family: MONO } } },
151
+ },
152
+ plugins: { legend: { display: false }, tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } } },
153
+ }}
154
+ />
155
+ </div>
156
+ ) : <div className="text-[10px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no tool data</div>}
157
+ </div>
158
+ </div>
159
+
160
+ {/* Token breakdown */}
161
+ {(project.totalCacheRead > 0 || project.totalCacheWrite > 0) && (
162
+ <div className="flex gap-4 text-[10px]" style={{ color: 'var(--c-text3)' }}>
163
+ <span>cache read: {formatNumber(project.totalCacheRead)}</span>
164
+ <span>cache write: {formatNumber(project.totalCacheWrite)}</span>
165
+ </div>
166
+ )}
167
+
168
+ {/* Sessions list */}
169
+ <div>
170
+ <div className="flex items-center gap-3 mb-3">
171
+ <h3 className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--c-text2)' }}>sessions ({chats.length})</h3>
172
+ <div className="relative max-w-xs flex-1">
173
+ <Search size={11} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
174
+ <input
175
+ type="text"
176
+ placeholder="filter sessions..."
177
+ value={chatSearch}
178
+ onChange={e => setChatSearch(e.target.value)}
179
+ className="w-full pl-7 pr-3 py-1 text-[11px] outline-none"
180
+ style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
181
+ />
182
+ </div>
183
+ </div>
184
+
185
+ <div className="card overflow-hidden">
186
+ <table className="w-full text-sm">
187
+ <thead>
188
+ <tr className="text-[10px] uppercase tracking-wider" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text3)' }}>
189
+ <th className="text-left py-2.5 px-4 font-medium">editor</th>
190
+ <th className="text-left py-2.5 px-4 font-medium">name</th>
191
+ <th className="text-left py-2.5 px-4 font-medium">mode</th>
192
+ <th className="text-left py-2.5 px-4 font-medium">model</th>
193
+ <th className="text-left py-2.5 px-4 font-medium">updated</th>
194
+ </tr>
195
+ </thead>
196
+ <tbody>
197
+ {filteredChats.map(c => (
198
+ <tr
199
+ key={c.id}
200
+ className="cursor-pointer transition"
201
+ style={{ borderBottom: '1px solid var(--c-border)' }}
202
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg3)'}
203
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
204
+ onClick={() => setSelectedChatId(c.id)}
205
+ >
206
+ <td className="py-2.5 px-4">
207
+ <EditorDot source={c.source} showLabel size={7} />
208
+ </td>
209
+ <td className="py-2.5 px-4 font-medium truncate max-w-[300px]" style={{ color: 'var(--c-white)' }}>
210
+ {c.name || <span style={{ color: 'var(--c-text3)' }}>(untitled)</span>}
211
+ {c.encrypted && <span className="ml-2 text-[10px] text-yellow-500/60">locked</span>}
212
+ </td>
213
+ <td className="py-2.5 px-4">
214
+ <span className="text-xs" style={{ color: 'var(--c-text2)' }}>{c.mode}</span>
215
+ </td>
216
+ <td className="py-2.5 px-4 text-xs truncate max-w-[180px] font-mono" style={{ color: 'var(--c-text2)' }} title={c.topModel || ''}>
217
+ {c.topModel || ''}
218
+ </td>
219
+ <td className="py-2.5 px-4 text-xs whitespace-nowrap" style={{ color: 'var(--c-text2)' }}>
220
+ {formatDate(c.lastUpdatedAt || c.createdAt)}
221
+ </td>
222
+ </tr>
223
+ ))}
224
+ </tbody>
225
+ </table>
226
+ {filteredChats.length === 0 && (
227
+ <div className="text-center py-8 text-sm" style={{ color: 'var(--c-text3)' }}>no sessions found</div>
228
+ )}
229
+ </div>
230
+ </div>
231
+
232
+ {/* Chat sidebar */}
233
+ <ChatSidebar chatId={selectedChatId} onClose={() => setSelectedChatId(null)} />
234
+ </div>
235
+ )
236
+ }
@@ -1,11 +1,13 @@
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, ChevronDown, ChevronUp } from 'lucide-react'
4
+ import { Search, ChevronRight } from 'lucide-react'
5
+ import { useNavigate } from 'react-router-dom'
5
6
  import { fetchProjects } from '../lib/api'
6
- import { editorColor, editorLabel, formatNumber, formatDate } from '../lib/constants'
7
+ import { editorColor, editorLabel, formatNumber, formatDate, dateRangeToApiParams } from '../lib/constants'
7
8
  import { useTheme } from '../lib/theme'
8
9
  import KpiCard from '../components/KpiCard'
10
+ import DateRangePicker from '../components/DateRangePicker'
9
11
 
10
12
  ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement)
11
13
 
@@ -20,12 +22,13 @@ export default function Projects({ overview }) {
20
22
  const [projects, setProjects] = useState(null)
21
23
  const [search, setSearch] = useState('')
22
24
  const [editorFilter, setEditorFilter] = useState('')
23
- const [expanded, setExpanded] = useState(null)
25
+ const [dateRange, setDateRange] = useState(null)
26
+ const navigate = useNavigate()
24
27
  const editors = overview?.editors || []
25
28
 
26
29
  useEffect(() => {
27
- fetchProjects().then(setProjects)
28
- }, [])
30
+ fetchProjects(dateRangeToApiParams(dateRange)).then(setProjects)
31
+ }, [dateRange])
29
32
 
30
33
  if (!projects) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading projects...</div>
31
34
 
@@ -109,133 +112,59 @@ export default function Projects({ overview }) {
109
112
 
110
113
  {/* Filters */}
111
114
  <div className="flex items-center gap-2">
112
- <select
113
- value={editorFilter}
114
- onChange={e => setEditorFilter(e.target.value)}
115
- className="px-2 py-2 text-sm outline-none"
116
- style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
117
- >
118
- <option value="">All Editors</option>
119
- {editors.map(e => (
120
- <option key={e.id} value={e.id}>{editorLabel(e.id)}</option>
121
- ))}
122
- </select>
123
- <div className="relative max-w-sm flex-1">
124
- <Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
125
- <input
126
- type="text"
127
- placeholder="search projects..."
128
- value={search}
129
- onChange={e => setSearch(e.target.value)}
130
- className="w-full pl-8 pr-3 py-2 text-sm outline-none"
115
+ <select
116
+ value={editorFilter}
117
+ onChange={e => setEditorFilter(e.target.value)}
118
+ className="px-2 py-1 text-[11px] outline-none"
131
119
  style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
132
- />
133
- </div>
120
+ >
121
+ <option value="">All Editors</option>
122
+ {editors.map(e => (
123
+ <option key={e.id} value={e.id}>{editorLabel(e.id)}</option>
124
+ ))}
125
+ </select>
126
+ <div className="relative max-w-sm flex-1">
127
+ <Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
128
+ <input
129
+ type="text"
130
+ placeholder="search projects..."
131
+ value={search}
132
+ onChange={e => setSearch(e.target.value)}
133
+ className="w-full pl-8 pr-3 py-1 text-[11px] outline-none"
134
+ style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
135
+ />
136
+ </div>
137
+ <div className="ml-auto"><DateRangePicker value={dateRange} onChange={setDateRange} /></div>
134
138
  </div>
135
139
 
136
140
  {/* Project list */}
137
141
  <div className="space-y-2">
138
142
  {filtered.map(p => {
139
- const isOpen = expanded === p.folder
140
143
  const editorEntries = Object.entries(p.editors).sort((a, b) => b[1] - a[1])
141
- const maxEditorCount = editorEntries.length > 0 ? editorEntries[0][1] : 1
142
144
 
143
145
  return (
144
- <div key={p.folder} className="card overflow-hidden">
145
- {/* Header row */}
146
- <div
147
- className="flex items-center gap-3 px-4 py-3 cursor-pointer transition"
148
- onClick={() => setExpanded(isOpen ? null : p.folder)}
149
- >
150
- <div className="flex-1 min-w-0">
151
- <div className="text-sm font-medium truncate" style={{ color: 'var(--c-white)' }}>{p.name}</div>
152
- <div className="text-[10px] truncate" style={{ color: 'var(--c-text3)' }}>{p.folder}</div>
153
- </div>
154
- <div className="flex items-center gap-4 text-[10px] flex-shrink-0" style={{ color: 'var(--c-text2)' }}>
155
- <div className="flex items-center gap-1.5">
156
- {editorEntries.slice(0, 4).map(([e]) => (
157
- <span key={e} className="w-2 h-2 rounded-full" style={{ background: editorColor(e) }} title={editorLabel(e)} />
158
- ))}
159
- </div>
160
- <span>{p.totalSessions} sessions</span>
161
- <span>{formatNumber(p.totalMessages)} msgs</span>
162
- {p.totalToolCalls > 0 && <span>{formatNumber(p.totalToolCalls)} tools</span>}
163
- {(p.totalInputTokens + p.totalOutputTokens) > 0 && <span>{formatNumber(p.totalInputTokens + p.totalOutputTokens)} tokens</span>}
164
- <span>{formatDate(p.lastSeen)}</span>
165
- {isOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
166
- </div>
146
+ <div
147
+ key={p.folder}
148
+ className="card px-4 py-3 flex items-center gap-3 cursor-pointer transition"
149
+ onClick={() => navigate(`/projects/detail?folder=${encodeURIComponent(p.folder)}`)}
150
+ >
151
+ <div className="flex-1 min-w-0">
152
+ <div className="text-sm font-medium truncate" style={{ color: 'var(--c-white)' }}>{p.name}</div>
153
+ <div className="text-[10px] truncate" style={{ color: 'var(--c-text3)' }}>{p.folder}</div>
167
154
  </div>
168
-
169
- {/* Expanded detail */}
170
- {isOpen && (
171
- <div className="px-4 pb-4 pt-1 fade-in" style={{ borderTop: '1px solid var(--c-border)' }}>
172
- <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3 mb-4">
173
- <KpiCard label="sessions" value={p.totalSessions} />
174
- <KpiCard label="messages" value={formatNumber(p.totalMessages)} />
175
- <KpiCard label="tool calls" value={formatNumber(p.totalToolCalls)} />
176
- <KpiCard label="input tokens" value={formatNumber(p.totalInputTokens)} />
177
- <KpiCard label="output tokens" value={formatNumber(p.totalOutputTokens)} />
178
- <KpiCard label="active since" value={formatDate(p.firstSeen)} />
179
- </div>
180
-
181
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
182
- {/* Editors */}
183
- <div>
184
- <h4 className="text-[10px] uppercase tracking-wider mb-2" style={{ color: 'var(--c-text2)' }}>editors</h4>
185
- <div className="space-y-1.5">
186
- {editorEntries.map(([e, c]) => (
187
- <div key={e} className="flex items-center gap-2">
188
- <span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: editorColor(e) }} />
189
- <span className="text-xs flex-1 truncate" style={{ color: 'var(--c-text2)' }}>{editorLabel(e)}</span>
190
- <div className="w-24 h-3 rounded-sm overflow-hidden" style={{ background: 'var(--c-code-bg)' }}>
191
- <div className="h-full rounded-sm" style={{ width: `${(c / maxEditorCount * 100).toFixed(0)}%`, background: editorColor(e) + '60' }} />
192
- </div>
193
- <span className="text-[10px] w-6 text-right" style={{ color: 'var(--c-text3)' }}>{c}</span>
194
- </div>
195
- ))}
196
- </div>
197
- </div>
198
-
199
- {/* Models */}
200
- <div>
201
- <h4 className="text-[10px] uppercase tracking-wider mb-2" style={{ color: 'var(--c-text2)' }}>models</h4>
202
- {p.topModels.length > 0 ? (
203
- <div className="space-y-1.5">
204
- {p.topModels.map(m => (
205
- <div key={m.name} className="flex justify-between text-xs py-0.5">
206
- <span className="truncate" style={{ color: 'var(--c-text2)' }}>{m.name}</span>
207
- <span className="ml-2" style={{ color: 'var(--c-text3)' }}>{m.count}</span>
208
- </div>
209
- ))}
210
- </div>
211
- ) : <div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>no model data</div>}
212
- </div>
213
-
214
- {/* Tools */}
215
- <div>
216
- <h4 className="text-[10px] uppercase tracking-wider mb-2" style={{ color: 'var(--c-text2)' }}>top tools</h4>
217
- {p.topTools.length > 0 ? (
218
- <div className="space-y-1.5">
219
- {p.topTools.map(t => (
220
- <div key={t.name} className="flex justify-between text-xs py-0.5">
221
- <span className="truncate" style={{ color: 'var(--c-text2)' }}>{t.name}</span>
222
- <span className="ml-2" style={{ color: 'var(--c-text3)' }}>{t.count}</span>
223
- </div>
224
- ))}
225
- </div>
226
- ) : <div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>no tool data</div>}
227
- </div>
228
- </div>
229
-
230
- {/* Token breakdown */}
231
- {(p.totalCacheRead > 0 || p.totalCacheWrite > 0) && (
232
- <div className="mt-3 flex gap-4 text-[10px]" style={{ color: 'var(--c-text3)' }}>
233
- <span>cache read: {formatNumber(p.totalCacheRead)}</span>
234
- <span>cache write: {formatNumber(p.totalCacheWrite)}</span>
235
- </div>
236
- )}
155
+ <div className="flex items-center gap-4 text-[10px] flex-shrink-0" style={{ color: 'var(--c-text2)' }}>
156
+ <div className="flex items-center gap-1.5">
157
+ {editorEntries.slice(0, 4).map(([e]) => (
158
+ <span key={e} className="w-2 h-2 rounded-full" style={{ background: editorColor(e) }} title={editorLabel(e)} />
159
+ ))}
237
160
  </div>
238
- )}
161
+ <span>{p.totalSessions} sessions</span>
162
+ <span>{formatNumber(p.totalMessages)} msgs</span>
163
+ {p.totalToolCalls > 0 && <span>{formatNumber(p.totalToolCalls)} tools</span>}
164
+ {(p.totalInputTokens + p.totalOutputTokens) > 0 && <span>{formatNumber(p.totalInputTokens + p.totalOutputTokens)} tokens</span>}
165
+ <span>{formatDate(p.lastSeen)}</span>
166
+ <ChevronRight size={14} />
167
+ </div>
239
168
  </div>
240
169
  )
241
170
  })}
@@ -1,12 +1,14 @@
1
1
  import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
2
- import { useNavigate, useSearchParams } from 'react-router-dom'
2
+ import { useSearchParams } from 'react-router-dom'
3
3
  import { Search, Filter, List, FolderOpen, ChevronDown, ChevronRight, X } from 'lucide-react'
4
4
  import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend, Filler } from 'chart.js'
5
5
  import { Line } from 'react-chartjs-2'
6
6
  import { fetchChats } from '../lib/api'
7
- import { editorColor, editorLabel, formatDate } from '../lib/constants'
7
+ import { editorColor, editorLabel, formatDate, dateRangeToApiParams } from '../lib/constants'
8
8
  import { useTheme } from '../lib/theme'
9
9
  import EditorDot from '../components/EditorDot'
10
+ import DateRangePicker from '../components/DateRangePicker'
11
+ import ChatSidebar from '../components/ChatSidebar'
10
12
 
11
13
  ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend, Filler)
12
14
 
@@ -107,8 +109,9 @@ export default function Sessions({ overview }) {
107
109
  const [loading, setLoading] = useState(true)
108
110
  const [groupByProject, setGroupByProject] = useState(false)
109
111
  const [collapsedProjects, setCollapsedProjects] = useState(new Set())
110
- const [dateRange, setDateRange] = useState(null) // [startWeek, endWeek]
111
- const navigate = useNavigate()
112
+ const [dateRange, setDateRange] = useState(null) // [startWeek, endWeek] — chart drag-select
113
+ const [apiDateRange, setApiDateRange] = useState(null) // { from, to } — server-side date filter
114
+ const [selectedChatId, setSelectedChatId] = useState(null)
112
115
  const chartRef = useRef(null)
113
116
 
114
117
  const onRangeSelect = useCallback((start, end) => {
@@ -117,12 +120,12 @@ export default function Sessions({ overview }) {
117
120
 
118
121
  useEffect(() => {
119
122
  setLoading(true)
120
- fetchChats({ editor, limit: 1000 }).then(data => {
123
+ fetchChats({ editor, limit: 1000, ...dateRangeToApiParams(apiDateRange) }).then(data => {
121
124
  setChats(data.chats)
122
125
  setTotal(data.total)
123
126
  setLoading(false)
124
127
  })
125
- }, [editor])
128
+ }, [editor, apiDateRange])
126
129
 
127
130
  const searchFiltered = search
128
131
  ? chats.filter(c =>
@@ -212,7 +215,7 @@ export default function Sessions({ overview }) {
212
215
  style={{ borderBottom: '1px solid var(--c-border)' }}
213
216
  onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg3)'}
214
217
  onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
215
- onClick={() => navigate(`/sessions/${c.id}`)}
218
+ onClick={() => setSelectedChatId(c.id)}
216
219
  >
217
220
  <td className="py-2.5 px-4">
218
221
  <EditorDot source={c.source} showLabel size={7} />
@@ -229,6 +232,9 @@ export default function Sessions({ overview }) {
229
232
  <td className="py-2.5 px-4">
230
233
  <span className="text-xs" style={{ color: 'var(--c-text2)' }}>{c.mode}</span>
231
234
  </td>
235
+ <td className="py-2.5 px-4 text-xs truncate max-w-[180px] font-mono" style={{ color: 'var(--c-text2)' }} title={c.topModel || ''}>
236
+ {c.topModel || ''}
237
+ </td>
232
238
  <td className="py-2.5 px-4 text-xs whitespace-nowrap" style={{ color: 'var(--c-text2)' }}>
233
239
  {formatDate(c.lastUpdatedAt || c.createdAt)}
234
240
  </td>
@@ -283,46 +289,48 @@ export default function Sessions({ overview }) {
283
289
 
284
290
  {/* Filters */}
285
291
  <div className="flex items-center gap-3">
286
- <div className="relative">
287
- <Filter size={13} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
288
- <select
289
- value={editor}
290
- onChange={e => setEditor(e.target.value)}
291
- className="pl-8 pr-3 py-2 text-sm outline-none appearance-none cursor-pointer"
292
- style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
292
+ <div className="relative">
293
+ <Filter size={13} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
294
+ <select
295
+ value={editor}
296
+ onChange={e => setEditor(e.target.value)}
297
+ className="pl-8 pr-3 py-1 text-[11px] outline-none appearance-none cursor-pointer"
298
+ style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
299
+ >
300
+ <option value="">all editors</option>
301
+ {editors.map(e => (
302
+ <option key={e.id} value={e.id}>{editorLabel(e.id)} ({e.count})</option>
303
+ ))}
304
+ </select>
305
+ </div>
306
+ <div className="relative flex-1 max-w-sm">
307
+ <Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
308
+ <input
309
+ type="text"
310
+ placeholder="search sessions..."
311
+ value={search}
312
+ onChange={e => setSearch(e.target.value)}
313
+ className="w-full pl-8 pr-3 py-1 text-[11px] outline-none"
314
+ style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
315
+ />
316
+ </div>
317
+ <button
318
+ onClick={() => setGroupByProject(!groupByProject)}
319
+ className="flex items-center gap-1.5 px-3 py-1 text-[11px] transition"
320
+ style={{
321
+ border: groupByProject ? '1px solid var(--c-accent)' : '1px solid var(--c-border)',
322
+ color: groupByProject ? 'var(--c-accent)' : 'var(--c-text2)',
323
+ background: groupByProject ? 'rgba(99,102,241,0.1)' : 'transparent',
324
+ }}
293
325
  >
294
- <option value="">all editors</option>
295
- {editors.map(e => (
296
- <option key={e.id} value={e.id}>{editorLabel(e.id)} ({e.count})</option>
297
- ))}
298
- </select>
299
- </div>
300
- <div className="relative flex-1 max-w-sm">
301
- <Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
302
- <input
303
- type="text"
304
- placeholder="search sessions..."
305
- value={search}
306
- onChange={e => setSearch(e.target.value)}
307
- className="w-full pl-8 pr-3 py-2 text-sm outline-none"
308
- style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
309
- />
310
- </div>
311
- <button
312
- onClick={() => setGroupByProject(!groupByProject)}
313
- className="flex items-center gap-1.5 px-3 py-2 text-xs transition"
314
- style={{
315
- border: groupByProject ? '1px solid var(--c-accent)' : '1px solid var(--c-border)',
316
- color: groupByProject ? 'var(--c-accent)' : 'var(--c-text2)',
317
- background: groupByProject ? 'rgba(99,102,241,0.1)' : 'transparent',
318
- }}
319
- >
320
- {groupByProject ? <FolderOpen size={13} /> : <List size={13} />}
321
- {groupByProject ? 'grouped' : 'flat'}
322
- </button>
323
- <span className="text-[10px]" style={{ color: 'var(--c-text3)' }}>
324
- {loading ? 'loading...' : `${filtered.length} of ${total}`}
325
- </span>
326
+ {groupByProject ? <FolderOpen size={13} /> : <List size={13} />}
327
+ {groupByProject ? 'grouped' : 'flat'}
328
+ </button>
329
+ <span className="text-[10px]" style={{ color: 'var(--c-text3)' }}>
330
+ {loading ? 'loading...' : `${filtered.length} of ${total}`}
331
+ </span>
332
+ {/* Server-side date range filter */}
333
+ <div className="ml-auto"><DateRangePicker value={apiDateRange} onChange={setApiDateRange} /></div>
326
334
  </div>
327
335
 
328
336
  {/* Session table */}
@@ -335,6 +343,7 @@ export default function Sessions({ overview }) {
335
343
  <th className="text-left py-2.5 px-4 font-medium">name</th>
336
344
  <th className="text-left py-2.5 px-4 font-medium">project</th>
337
345
  <th className="text-left py-2.5 px-4 font-medium">mode</th>
346
+ <th className="text-left py-2.5 px-4 font-medium">model</th>
338
347
  <th className="text-left py-2.5 px-4 font-medium">updated</th>
339
348
  </tr>
340
349
  </thead>
@@ -383,6 +392,9 @@ export default function Sessions({ overview }) {
383
392
  )}
384
393
  </div>
385
394
  )}
395
+
396
+ {/* Chat sidebar */}
397
+ <ChatSidebar chatId={selectedChatId} onClose={() => setSelectedChatId(null)} />
386
398
  </div>
387
399
  )
388
400
  }