memhouse 0.0.1 → 0.3.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.
Files changed (96) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +59 -3
  3. package/bin/memhouse.js +487 -0
  4. package/cache.js +850 -0
  5. package/ch-ingest.js +329 -0
  6. package/ch-schema.js +152 -0
  7. package/clickhouse.js +87 -0
  8. package/editors/antigravity.js +1019 -0
  9. package/editors/base.js +321 -0
  10. package/editors/claude.js +386 -0
  11. package/editors/codebuff.js +338 -0
  12. package/editors/codex.js +537 -0
  13. package/editors/commandcode.js +166 -0
  14. package/editors/copilot-jetbrains.js +207 -0
  15. package/editors/copilot.js +260 -0
  16. package/editors/cursor-agent.js +202 -0
  17. package/editors/cursor.js +438 -0
  18. package/editors/gemini.js +231 -0
  19. package/editors/goose.js +358 -0
  20. package/editors/gsd.js +366 -0
  21. package/editors/index.js +179 -0
  22. package/editors/kiro.js +315 -0
  23. package/editors/opencode.js +374 -0
  24. package/editors/vscode.js +413 -0
  25. package/editors/windsurf.js +634 -0
  26. package/editors/zed.js +213 -0
  27. package/index.js +424 -2
  28. package/mcp-server.js +294 -0
  29. package/mem-house/DESIGN.md +119 -0
  30. package/mem-house/delivery/AGENT-INSTALL.md +75 -0
  31. package/mem-house/delivery/PROMPT.md +42 -0
  32. package/mem-house/delivery/install.sh +181 -0
  33. package/mem-house/delivery/kernel-install.md +76 -0
  34. package/mem-house/delivery/plugin/.claude-plugin/plugin.json +8 -0
  35. package/mem-house/delivery/skills/memhouse-search/SKILL.md +104 -0
  36. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +94 -0
  37. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +115 -0
  38. package/mem-house/rls.sql +17 -0
  39. package/mem-house/schema.sql +145 -0
  40. package/mem-house/server/queries.js +878 -0
  41. package/mem-house/server/server.js +228 -0
  42. package/mem-house/shipper/ship.js +402 -0
  43. package/misc/syntax-check.js +38 -0
  44. package/package.json +56 -7
  45. package/pricing.js +88 -0
  46. package/pricing.json +992 -0
  47. package/public/assets/index-BsgjOSTv.js +75 -0
  48. package/public/assets/index-DV6ONi_F.css +2 -0
  49. package/public/index.html +16 -0
  50. package/relay-client.js +306 -0
  51. package/relay-server.js +577 -0
  52. package/server.js +860 -0
  53. package/share-image.js +367 -0
  54. package/ui/eslint.config.js +29 -0
  55. package/ui/index.html +15 -0
  56. package/ui/package-lock.json +4229 -0
  57. package/ui/package.json +38 -0
  58. package/ui/src/App.jsx +364 -0
  59. package/ui/src/components/ActivityHeatmap.jsx +206 -0
  60. package/ui/src/components/AiAuditCard.jsx +254 -0
  61. package/ui/src/components/AnimatedLoader.jsx +14 -0
  62. package/ui/src/components/AnimatedLogo.jsx +96 -0
  63. package/ui/src/components/ChatSidebar.jsx +226 -0
  64. package/ui/src/components/DateRangePicker.jsx +104 -0
  65. package/ui/src/components/EditorDot.jsx +11 -0
  66. package/ui/src/components/EditorIcon.jsx +117 -0
  67. package/ui/src/components/KpiCard.jsx +9 -0
  68. package/ui/src/components/LiveFeed.jsx +128 -0
  69. package/ui/src/components/LoginScreen.jsx +81 -0
  70. package/ui/src/components/MessageRenderer.jsx +148 -0
  71. package/ui/src/components/PageHeader.jsx +11 -0
  72. package/ui/src/components/SectionTitle.jsx +3 -0
  73. package/ui/src/components/ShareModal.jsx +305 -0
  74. package/ui/src/components/TokenTimeline.jsx +258 -0
  75. package/ui/src/hooks/useLive.jsx +15 -0
  76. package/ui/src/index.css +83 -0
  77. package/ui/src/lib/api.js +343 -0
  78. package/ui/src/lib/constants.js +88 -0
  79. package/ui/src/lib/theme.jsx +23 -0
  80. package/ui/src/main.jsx +16 -0
  81. package/ui/src/pages/Artifacts.jsx +575 -0
  82. package/ui/src/pages/Compare.jsx +232 -0
  83. package/ui/src/pages/CostAnalysis.jsx +354 -0
  84. package/ui/src/pages/Dashboard.jsx +529 -0
  85. package/ui/src/pages/DeepAnalysis.jsx +495 -0
  86. package/ui/src/pages/GSD.jsx +726 -0
  87. package/ui/src/pages/MCPs.jsx +744 -0
  88. package/ui/src/pages/ProjectDetail.jsx +325 -0
  89. package/ui/src/pages/Projects.jsx +249 -0
  90. package/ui/src/pages/RelayDashboard.jsx +590 -0
  91. package/ui/src/pages/RelayUserDetail.jsx +466 -0
  92. package/ui/src/pages/Sessions.jsx +535 -0
  93. package/ui/src/pages/Settings.jsx +241 -0
  94. package/ui/src/pages/SqlViewer.jsx +344 -0
  95. package/ui/src/pages/Subscriptions.jsx +411 -0
  96. package/ui/vite.config.js +18 -0
@@ -0,0 +1,535 @@
1
+ import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
2
+ import { useSearchParams, useNavigate } from 'react-router-dom'
3
+ import { Search, Filter, List, FolderOpen, ChevronDown, ChevronRight, X, AlertTriangle } from 'lucide-react'
4
+ import { Chart as ChartJS, ArcElement, CategoryScale, LinearScale, PointElement, LineElement, BarElement, Tooltip, Legend, Filler } from 'chart.js'
5
+ import { Line, Doughnut, Bar } from 'react-chartjs-2'
6
+ import { fetchChats } from '../lib/api'
7
+ import { editorColor, editorLabel, formatNumber, formatCost, formatDate, dateRangeToApiParams } from '../lib/constants'
8
+ import { useTheme } from '../lib/theme'
9
+ import KpiCard from '../components/KpiCard'
10
+ import EditorIcon from '../components/EditorIcon'
11
+ import SectionTitle from '../components/SectionTitle'
12
+ import DateRangePicker from '../components/DateRangePicker'
13
+ import ChatSidebar from '../components/ChatSidebar'
14
+ import PageHeader from '../components/PageHeader'
15
+
16
+ ChartJS.register(ArcElement, CategoryScale, LinearScale, PointElement, LineElement, BarElement, Tooltip, Legend, Filler)
17
+
18
+ const MONO = 'JetBrains Mono, monospace'
19
+
20
+ // Custom Chart.js plugin: drag-to-select range on x-axis
21
+ const dragSelectPlugin = {
22
+ id: 'dragSelect',
23
+ beforeEvent(chart, args) {
24
+ const evt = args.event
25
+ const state = chart._dragSelect || (chart._dragSelect = { dragging: false, startX: 0, endX: 0 })
26
+ const area = chart.chartArea
27
+ if (!area) return
28
+
29
+ if (evt.type === 'mousedown' && evt.x >= area.left && evt.x <= area.right && evt.y >= area.top && evt.y <= area.bottom) {
30
+ state.dragging = true
31
+ state.startX = evt.x
32
+ state.endX = evt.x
33
+ }
34
+ if (evt.type === 'mousemove' && state.dragging) {
35
+ state.endX = Math.max(area.left, Math.min(evt.x, area.right))
36
+ args.changed = false
37
+ chart.draw()
38
+ return false
39
+ }
40
+ if (evt.type === 'mouseup' && state.dragging) {
41
+ state.dragging = false
42
+ state.endX = Math.max(area.left, Math.min(evt.x, area.right))
43
+ const x1 = Math.min(state.startX, state.endX)
44
+ const x2 = Math.max(state.startX, state.endX)
45
+ if (x2 - x1 > 5) {
46
+ const scale = chart.scales.x
47
+ const startIdx = scale.getValueForPixel(x1)
48
+ const endIdx = scale.getValueForPixel(x2)
49
+ const labels = chart.data.labels
50
+ const startLabel = labels[Math.max(0, Math.round(startIdx))]
51
+ const endLabel = labels[Math.min(labels.length - 1, Math.round(endIdx))]
52
+ if (chart.config.options._onRangeSelect) {
53
+ chart.config.options._onRangeSelect(startLabel, endLabel)
54
+ }
55
+ }
56
+ chart.draw()
57
+ }
58
+ },
59
+ afterDraw(chart) {
60
+ const state = chart._dragSelect
61
+ if (!state) return
62
+ const area = chart.chartArea
63
+ const ctx = chart.ctx
64
+ // Draw persisted selection highlight
65
+ const opts = chart.config.options
66
+ if (opts._selectionRange && chart.data.labels) {
67
+ const scale = chart.scales.x
68
+ const labels = chart.data.labels
69
+ const si = labels.indexOf(opts._selectionRange[0])
70
+ const ei = labels.indexOf(opts._selectionRange[1])
71
+ if (si >= 0 && ei >= 0) {
72
+ const px1 = scale.getPixelForValue(si)
73
+ const px2 = scale.getPixelForValue(ei)
74
+ ctx.save()
75
+ ctx.fillStyle = 'rgba(99,102,241,0.10)'
76
+ ctx.fillRect(Math.min(px1, px2), area.top, Math.abs(px2 - px1), area.bottom - area.top)
77
+ ctx.strokeStyle = 'rgba(99,102,241,0.4)'
78
+ ctx.lineWidth = 1
79
+ ctx.setLineDash([3, 3])
80
+ ctx.strokeRect(Math.min(px1, px2), area.top, Math.abs(px2 - px1), area.bottom - area.top)
81
+ ctx.restore()
82
+ }
83
+ }
84
+ // Draw active drag overlay
85
+ if (state.dragging) {
86
+ const x1 = Math.min(state.startX, state.endX)
87
+ const x2 = Math.max(state.startX, state.endX)
88
+ ctx.save()
89
+ ctx.fillStyle = 'rgba(99,102,241,0.15)'
90
+ ctx.fillRect(x1, area.top, x2 - x1, area.bottom - area.top)
91
+ ctx.restore()
92
+ }
93
+ },
94
+ }
95
+ ChartJS.register(dragSelectPlugin)
96
+
97
+ // Custom tooltip positioner: below cursor
98
+ Tooltip.positioners.belowCursor = function(elements, eventPosition) {
99
+ return { x: eventPosition.x, y: eventPosition.y + 20 }
100
+ }
101
+
102
+ export default function Sessions({ overview }) {
103
+ const { dark } = useTheme()
104
+ const txtDim = dark ? '#555' : '#999'
105
+ const legendColor = dark ? '#777' : '#555'
106
+ const gridColor = dark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.06)'
107
+ const [chats, setChats] = useState([])
108
+ const [total, setTotal] = useState(0)
109
+ const [search, setSearch] = useState('')
110
+ const [searchParams] = useSearchParams()
111
+ const navigate = useNavigate()
112
+ const [editor, setEditor] = useState(searchParams.get('editor') || '')
113
+ const [loading, setLoading] = useState(true)
114
+ const [groupByProject, setGroupByProject] = useState(false)
115
+ const [collapsedProjects, setCollapsedProjects] = useState(new Set())
116
+ const [dateRange, setDateRange] = useState(null) // [startWeek, endWeek] — chart drag-select
117
+ const [apiDateRange, setApiDateRange] = useState(null) // { from, to } — server-side date filter
118
+ const [selectedChatId, setSelectedChatId] = useState(null)
119
+ const chartRef = useRef(null)
120
+
121
+ const onRangeSelect = useCallback((start, end) => {
122
+ if (start && end) setDateRange([start, end].sort())
123
+ }, [])
124
+
125
+ useEffect(() => {
126
+ setLoading(true)
127
+ fetchChats({ editor, limit: 1000, ...dateRangeToApiParams(apiDateRange) }).then(data => {
128
+ setChats(data.chats)
129
+ setTotal(data.total)
130
+ setLoading(false)
131
+ })
132
+ }, [editor, apiDateRange])
133
+
134
+ const searchFiltered = search
135
+ ? chats.filter(c =>
136
+ (c.name || '').toLowerCase().includes(search.toLowerCase()) ||
137
+ (c.folder || '').toLowerCase().includes(search.toLowerCase()) ||
138
+ c.id.toLowerCase().includes(search.toLowerCase())
139
+ )
140
+ : chats
141
+
142
+ // Apply date range filter
143
+ const filtered = useMemo(() => {
144
+ if (!dateRange) return searchFiltered
145
+ const [start, end] = dateRange
146
+ // Add 6 days to end week to cover full week
147
+ const startMs = new Date(start).getTime()
148
+ const endMs = new Date(end).getTime() + 6 * 86400000
149
+ return searchFiltered.filter(c => {
150
+ const ts = c.lastUpdatedAt || c.createdAt
151
+ return ts && ts >= startMs && ts <= endMs
152
+ })
153
+ }, [searchFiltered, dateRange])
154
+
155
+ const editors = overview?.editors || []
156
+ const MODEL_COLORS = ['#6366f1', '#a78bfa', '#818cf8', '#c084fc', '#e879f9', '#f472b6', '#fb7185', '#f87171', '#fbbf24', '#34d399']
157
+
158
+ // Summary stats from filtered chats
159
+ const stats = useMemo(() => {
160
+ const editorCounts = {}
161
+ const modelCounts = {}
162
+ const modeCounts = {}
163
+ let bloatCount = 0
164
+ let largeCount = 0
165
+ const projectSet = new Set()
166
+ for (const c of filtered) {
167
+ if (c.source) editorCounts[c.source] = (editorCounts[c.source] || 0) + 1
168
+ if (c.topModel) modelCounts[c.topModel] = (modelCounts[c.topModel] || 0) + 1
169
+ if (c.mode) modeCounts[c.mode] = (modeCounts[c.mode] || 0) + 1
170
+ if (c.folder) projectSet.add(c.folder)
171
+ if (c.bubbleCount >= 500) bloatCount++
172
+ else if (c.bubbleCount >= 200) largeCount++
173
+ }
174
+ return {
175
+ editorEntries: Object.entries(editorCounts).sort((a, b) => b[1] - a[1]),
176
+ modelEntries: Object.entries(modelCounts).sort((a, b) => b[1] - a[1]).slice(0, 10),
177
+ modeEntries: Object.entries(modeCounts).sort((a, b) => b[1] - a[1]),
178
+ bloatCount,
179
+ largeCount,
180
+ projectCount: projectSet.size,
181
+ }
182
+ }, [filtered])
183
+
184
+ // Timeline chart data: sessions per week by editor (always use full list, not date-filtered)
185
+ const timelineChart = useMemo(() => {
186
+ if (searchFiltered.length === 0) return null
187
+ const weekMap = {}
188
+ for (const c of searchFiltered) {
189
+ const ts = c.lastUpdatedAt || c.createdAt
190
+ if (!ts) continue
191
+ const d = new Date(ts)
192
+ const day = d.getDay()
193
+ const diff = d.getDate() - day + (day === 0 ? -6 : 1)
194
+ const monday = new Date(d)
195
+ monday.setDate(diff)
196
+ const wk = `${monday.getFullYear()}-${String(monday.getMonth() + 1).padStart(2, '0')}-${String(monday.getDate()).padStart(2, '0')}`
197
+ if (!weekMap[wk]) weekMap[wk] = {}
198
+ const src = c.source || 'unknown'
199
+ weekMap[wk][src] = (weekMap[wk][src] || 0) + 1
200
+ }
201
+ const weeks = Object.keys(weekMap).sort()
202
+ const editorIds = [...new Set(searchFiltered.map(c => c.source).filter(Boolean))]
203
+ return {
204
+ labels: weeks,
205
+ datasets: editorIds.map(eid => ({
206
+ label: editorLabel(eid),
207
+ data: weeks.map(w => weekMap[w][eid] || 0),
208
+ borderColor: editorColor(eid),
209
+ backgroundColor: editorColor(eid) + '15',
210
+ borderWidth: 1.5,
211
+ tension: 0.3,
212
+ pointRadius: 0,
213
+ pointHoverRadius: 4,
214
+ fill: true,
215
+ })),
216
+ }
217
+ }, [searchFiltered])
218
+
219
+ // Group by project
220
+ const grouped = useMemo(() => {
221
+ if (!groupByProject) return null
222
+ const map = {}
223
+ for (const c of filtered) {
224
+ const key = c.folder || '(no project)'
225
+ if (!map[key]) map[key] = []
226
+ map[key].push(c)
227
+ }
228
+ return Object.entries(map)
229
+ .map(([folder, items]) => ({ folder, name: folder === '(no project)' ? '(no project)' : folder.split('/').pop(), items }))
230
+ .sort((a, b) => b.items.length - a.items.length)
231
+ }, [filtered, groupByProject])
232
+
233
+ function toggleProject(folder) {
234
+ setCollapsedProjects(prev => {
235
+ const next = new Set(prev)
236
+ if (next.has(folder)) next.delete(folder)
237
+ else next.add(folder)
238
+ return next
239
+ })
240
+ }
241
+
242
+ const SessionRow = ({ c }) => (
243
+ <tr
244
+ key={c.id}
245
+ className="cursor-pointer transition"
246
+ style={{ borderBottom: '1px solid var(--c-border)' }}
247
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg3)'}
248
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
249
+ onClick={() => setSelectedChatId(c.id)}
250
+ >
251
+ <td className="py-2 px-3">
252
+ <span className="inline-flex items-center gap-1.5">
253
+ <EditorIcon source={c.source} size={12} />
254
+ <span className="text-[12px]" style={{ color: 'var(--c-text2)' }}>{editorLabel(c.source)}</span>
255
+ </span>
256
+ </td>
257
+ <td className="py-2 px-3 font-medium truncate max-w-[280px] text-[12px]" style={{ color: 'var(--c-white)' }}>
258
+ {c.name || <span style={{ color: 'var(--c-text3)' }}>Untitled</span>}
259
+ {c.encrypted && <span className="ml-1.5 text-[10px] text-yellow-500/60">locked</span>}
260
+ </td>
261
+ {!groupByProject && (
262
+ <td className="py-2 px-3 truncate max-w-[160px] text-[12px]" style={{ color: 'var(--c-text2)' }} title={c.folder}>
263
+ {c.folder ? (
264
+ <span
265
+ className="cursor-pointer hover:underline"
266
+ style={{ color: 'var(--c-accent)' }}
267
+ onClick={e => { e.stopPropagation(); navigate(`/projects/detail?folder=${encodeURIComponent(c.folder)}`) }}
268
+ >{c.folder.split('/').pop()}</span>
269
+ ) : ''}
270
+ </td>
271
+ )}
272
+ <td className="py-2 px-3 text-[12px]" style={{ color: 'var(--c-text2)' }}>{c.mode || ''}</td>
273
+ <td className="py-2 px-3 text-[12px] font-mono truncate max-w-[150px]" style={{ color: 'var(--c-text2)' }} title={c.topModel || ''}>
274
+ {c.topModel || ''}
275
+ </td>
276
+ <td className="py-2 px-3 text-[12px]">
277
+ {c.bubbleCount >= 500 ? (
278
+ <span className="inline-flex items-center gap-0.5 font-bold" style={{ color: '#ef4444' }}>
279
+ <AlertTriangle size={9} />{c.bubbleCount}
280
+ </span>
281
+ ) : c.bubbleCount >= 200 ? (
282
+ <span className="inline-flex items-center gap-0.5 font-bold" style={{ color: '#f59e0b' }}>
283
+ <AlertTriangle size={9} />{c.bubbleCount}
284
+ </span>
285
+ ) : (
286
+ <span style={{ color: 'var(--c-text3)' }}>{c.bubbleCount || 0}</span>
287
+ )}
288
+ </td>
289
+ <td className="py-2 px-3 text-[12px] font-mono text-right" style={{ color: c.cost > 0 ? 'var(--c-text2)' : 'var(--c-text3)' }}>
290
+ {c.cost > 0 ? formatCost(c.cost) : ''}
291
+ </td>
292
+ <td className="py-2 px-3 text-[12px] whitespace-nowrap" style={{ color: 'var(--c-text3)' }}>
293
+ {formatDate(c.lastUpdatedAt || c.createdAt)}
294
+ </td>
295
+ </tr>
296
+ )
297
+
298
+ return (
299
+ <div className="fade-in space-y-3">
300
+ <PageHeader icon={List} title="Sessions" />
301
+
302
+ {/* KPIs */}
303
+ <div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(90px, 1fr))' }}>
304
+ <KpiCard label="sessions" value={formatNumber(filtered.length)} sub={filtered.length !== total ? `of ${formatNumber(total)}` : ''} />
305
+ <KpiCard label="projects" value={stats.projectCount} />
306
+ <KpiCard label="editors" value={stats.editorEntries.length} />
307
+ <KpiCard label="models" value={stats.modelEntries.length} />
308
+ {(stats.bloatCount + stats.largeCount) > 0 && (
309
+ <KpiCard label="large context" value={stats.bloatCount + stats.largeCount} sub={stats.bloatCount > 0 ? `${stats.bloatCount} bloated` : ''} />
310
+ )}
311
+ </div>
312
+
313
+ {/* Summary charts */}
314
+ {filtered.length > 0 && (
315
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
316
+ <div className="card p-3">
317
+ <SectionTitle>editors</SectionTitle>
318
+ <div style={{ height: 140 }}>
319
+ <Doughnut
320
+ data={{
321
+ labels: stats.editorEntries.map(e => editorLabel(e[0])),
322
+ datasets: [{ data: stats.editorEntries.map(e => e[1]), backgroundColor: stats.editorEntries.map(e => editorColor(e[0])), borderWidth: 0 }],
323
+ }}
324
+ options={{
325
+ responsive: true, maintainAspectRatio: false, cutout: '60%',
326
+ plugins: {
327
+ legend: { position: 'right', labels: { color: legendColor, font: { size: 9, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
328
+ tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
329
+ },
330
+ }}
331
+ />
332
+ </div>
333
+ </div>
334
+ <div className="card p-3">
335
+ <SectionTitle>models</SectionTitle>
336
+ <div style={{ height: 140 }}>
337
+ {stats.modelEntries.length > 0 ? (
338
+ <Doughnut
339
+ data={{
340
+ labels: stats.modelEntries.map(m => m[0]),
341
+ datasets: [{ data: stats.modelEntries.map(m => m[1]), backgroundColor: MODEL_COLORS, borderWidth: 0 }],
342
+ }}
343
+ options={{
344
+ responsive: true, maintainAspectRatio: false, cutout: '60%',
345
+ plugins: {
346
+ legend: { position: 'right', labels: { color: legendColor, font: { size: 9, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
347
+ tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
348
+ },
349
+ }}
350
+ />
351
+ ) : <div className="text-[11px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no model data</div>}
352
+ </div>
353
+ </div>
354
+ <div className="card p-3">
355
+ <SectionTitle>modes</SectionTitle>
356
+ <div style={{ height: 140 }}>
357
+ {stats.modeEntries.length > 0 ? (
358
+ <Bar
359
+ data={{
360
+ labels: stats.modeEntries.map(m => m[0] || 'unknown'),
361
+ datasets: [{
362
+ data: stats.modeEntries.map(m => m[1]),
363
+ backgroundColor: '#6366f1',
364
+ borderRadius: 3,
365
+ }],
366
+ }}
367
+ options={{
368
+ responsive: true, maintainAspectRatio: false, indexAxis: 'y',
369
+ scales: {
370
+ x: { grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 8, family: MONO } } },
371
+ y: { grid: { display: false }, ticks: { color: legendColor, font: { size: 9, family: MONO } } },
372
+ },
373
+ plugins: { legend: { display: false }, tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } } },
374
+ }}
375
+ />
376
+ ) : <div className="text-[11px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no mode data</div>}
377
+ </div>
378
+ </div>
379
+ </div>
380
+ )}
381
+
382
+ {/* Timeline chart */}
383
+ {timelineChart && timelineChart.labels.length > 1 && (
384
+ <div className="card p-3">
385
+ <div className="flex items-center justify-between mb-2">
386
+ <SectionTitle>
387
+ session timeline
388
+ <span className="ml-2 font-normal text-[10px]" style={{ color: 'var(--c-text3)' }}>(drag to select range)</span>
389
+ </SectionTitle>
390
+ {dateRange && (
391
+ <button
392
+ onClick={() => setDateRange(null)}
393
+ className="flex items-center gap-1 text-[11px] px-2 py-0.5 transition"
394
+ style={{ color: 'var(--c-accent)', border: '1px solid var(--c-border)' }}
395
+ >
396
+ <X size={10} />
397
+ {dateRange[0]} — {dateRange[1]}
398
+ </button>
399
+ )}
400
+ </div>
401
+ <div style={{ height: 160 }}>
402
+ <Line
403
+ ref={chartRef}
404
+ data={timelineChart}
405
+ options={{
406
+ responsive: true,
407
+ maintainAspectRatio: false,
408
+ interaction: { mode: 'index', intersect: false },
409
+ events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove', 'mousedown', 'mouseup'],
410
+ _onRangeSelect: onRangeSelect,
411
+ _selectionRange: dateRange,
412
+ scales: {
413
+ x: { grid: { display: false }, ticks: { color: txtDim, font: { size: 9, family: MONO }, maxTicksLimit: 20, maxRotation: 0 } },
414
+ y: { beginAtZero: true, grid: { color: gridColor }, ticks: { color: txtDim, stepSize: 1, font: { size: 9, family: MONO } } },
415
+ },
416
+ plugins: {
417
+ legend: { position: 'top', labels: { color: legendColor, font: { size: 9, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 10 } },
418
+ tooltip: { position: 'belowCursor', bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
419
+ },
420
+ }}
421
+ />
422
+ </div>
423
+ </div>
424
+ )}
425
+
426
+ {/* Filters */}
427
+ <div className="flex items-center gap-2">
428
+ <select
429
+ value={editor}
430
+ onChange={e => setEditor(e.target.value)}
431
+ className="px-2 py-1 text-[12px] outline-none"
432
+ style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
433
+ >
434
+ <option value="">All Editors</option>
435
+ {editors.map(e => (
436
+ <option key={e.id} value={e.id}>{editorLabel(e.id)} ({e.count})</option>
437
+ ))}
438
+ </select>
439
+ <div className="relative flex-1 max-w-sm">
440
+ <Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
441
+ <input
442
+ type="text"
443
+ placeholder="Search sessions..."
444
+ value={search}
445
+ onChange={e => setSearch(e.target.value)}
446
+ className="w-full pl-7 pr-3 py-1 text-[12px] outline-none"
447
+ style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
448
+ />
449
+ </div>
450
+ <button
451
+ onClick={() => setGroupByProject(!groupByProject)}
452
+ className="flex items-center gap-1.5 px-3 py-1 text-[12px] transition"
453
+ style={{
454
+ border: groupByProject ? '1px solid var(--c-accent)' : '1px solid var(--c-border)',
455
+ color: groupByProject ? 'var(--c-accent)' : 'var(--c-text2)',
456
+ background: groupByProject ? 'rgba(99,102,241,0.1)' : 'transparent',
457
+ }}
458
+ >
459
+ {groupByProject ? <FolderOpen size={13} /> : <List size={13} />}
460
+ {groupByProject ? 'grouped' : 'flat'}
461
+ </button>
462
+ <span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>
463
+ {loading ? 'loading...' : `${filtered.length} of ${total}`}
464
+ </span>
465
+ {/* Server-side date range filter */}
466
+ <div className="ml-auto"><DateRangePicker value={apiDateRange} onChange={setApiDateRange} /></div>
467
+ </div>
468
+
469
+ {/* Session table */}
470
+ {!groupByProject ? (
471
+ <div className="card overflow-hidden">
472
+ <table className="w-full text-sm">
473
+ <thead>
474
+ <tr className="text-[10px] uppercase tracking-wider" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text3)' }}>
475
+ <th className="text-left py-2 px-3 font-medium">editor</th>
476
+ <th className="text-left py-2 px-3 font-medium">name</th>
477
+ <th className="text-left py-2 px-3 font-medium">project</th>
478
+ <th className="text-left py-2 px-3 font-medium">mode</th>
479
+ <th className="text-left py-2 px-3 font-medium">model</th>
480
+ <th className="text-left py-2 px-3 font-medium">context</th>
481
+ <th className="text-right py-2 px-3 font-medium">est. cost</th>
482
+ <th className="text-left py-2 px-3 font-medium">updated</th>
483
+ </tr>
484
+ </thead>
485
+ <tbody>
486
+ {filtered.map(c => <SessionRow key={c.id} c={c} />)}
487
+ </tbody>
488
+ </table>
489
+ {filtered.length === 0 && !loading && (
490
+ <div className="text-center py-12 text-sm" style={{ color: 'var(--c-text3)' }}>no sessions found</div>
491
+ )}
492
+ </div>
493
+ ) : (
494
+ <div className="space-y-2">
495
+ {grouped.map(g => {
496
+ const isCollapsed = collapsedProjects.has(g.folder)
497
+ const editorSet = [...new Set(g.items.map(c => c.source).filter(Boolean))]
498
+ return (
499
+ <div key={g.folder} className="card overflow-hidden">
500
+ <div
501
+ className="flex items-center gap-2 px-4 py-2.5 cursor-pointer transition"
502
+ onClick={() => toggleProject(g.folder)}
503
+ >
504
+ {isCollapsed ? <ChevronRight size={13} style={{ color: 'var(--c-text3)' }} /> : <ChevronDown size={13} style={{ color: 'var(--c-text3)' }} />}
505
+ <FolderOpen size={13} style={{ color: 'var(--c-text3)' }} />
506
+ <span className="text-xs font-medium" style={{ color: 'var(--c-white)' }}>{g.name}</span>
507
+ <span className="text-[11px] truncate max-w-[300px]" style={{ color: 'var(--c-text3)' }}>{g.folder !== g.name && g.folder !== '(no project)' ? g.folder : ''}</span>
508
+ <span className="ml-auto flex items-center gap-2">
509
+ {editorSet.slice(0, 5).map(e => (
510
+ <span key={e} className="w-2 h-2 rounded-full" style={{ background: editorColor(e) }} title={editorLabel(e)} />
511
+ ))}
512
+ <span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>{g.items.length}</span>
513
+ </span>
514
+ </div>
515
+ {!isCollapsed && (
516
+ <table className="w-full text-sm">
517
+ <tbody>
518
+ {g.items.map(c => <SessionRow key={c.id} c={c} />)}
519
+ </tbody>
520
+ </table>
521
+ )}
522
+ </div>
523
+ )
524
+ })}
525
+ {grouped.length === 0 && !loading && (
526
+ <div className="text-center py-12 text-sm" style={{ color: 'var(--c-text3)' }}>no sessions found</div>
527
+ )}
528
+ </div>
529
+ )}
530
+
531
+ {/* Chat sidebar */}
532
+ <ChatSidebar chatId={selectedChatId} onClose={() => setSelectedChatId(null)} />
533
+ </div>
534
+ )
535
+ }