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,325 @@
1
+ import { useState, useEffect, useMemo } from 'react'
2
+ import { useSearchParams, useNavigate } from 'react-router-dom'
3
+ import { ArrowLeft, Search, FolderOpen, Calendar, MessageSquare, Wrench, Cpu, Zap, AlertTriangle, ShieldCheck } 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, fetchCosts } from '../lib/api'
7
+ import { editorColor, editorLabel, formatNumber, formatDate, formatCost } 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 ChatSidebar from '../components/ChatSidebar'
13
+ import AnimatedLoader from '../components/AnimatedLoader'
14
+ import AiAuditCard from '../components/AiAuditCard'
15
+
16
+ ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement)
17
+
18
+ const MONO = 'JetBrains Mono, monospace'
19
+ const MODEL_COLORS = ['#6366f1', '#a78bfa', '#818cf8', '#c084fc', '#e879f9', '#f472b6', '#fb7185', '#f87171', '#fbbf24', '#34d399']
20
+ const TOOL_COLORS = ['#10b981', '#34d399', '#6ee7b7', '#a7f3d0', '#d1fae5', '#ecfdf5', '#b8f0d8', '#7ce0b8', '#4ade80', '#22c55e']
21
+
22
+ export default function ProjectDetail() {
23
+ const [searchParams] = useSearchParams()
24
+ const navigate = useNavigate()
25
+ const folder = searchParams.get('folder')
26
+ const { dark } = useTheme()
27
+ const txtColor = dark ? '#888' : '#555'
28
+ const txtDim = dark ? '#555' : '#999'
29
+ const gridColor = dark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.06)'
30
+
31
+ const [project, setProject] = useState(null)
32
+ const [chats, setChats] = useState([])
33
+ const [loading, setLoading] = useState(true)
34
+ const [chatSearch, setChatSearch] = useState('')
35
+ const [selectedChatId, setSelectedChatId] = useState(null)
36
+ const [costs, setCosts] = useState(null)
37
+ const [enabledEditors, setEnabledEditors] = useState(null)
38
+
39
+ useEffect(() => {
40
+ if (!folder) return
41
+ setLoading(true)
42
+ Promise.all([
43
+ fetchProjects(),
44
+ fetchChats({ folder, limit: 1000 }),
45
+ fetchCosts({ folder }),
46
+ ]).then(([projects, chatData, costData]) => {
47
+ const match = projects.find(p => p.folder === folder)
48
+ setProject(match || null)
49
+ setChats(chatData.chats || [])
50
+ setCosts(costData)
51
+ if (match) setEnabledEditors(new Set(Object.keys(match.editors)))
52
+ setLoading(false)
53
+ })
54
+ }, [folder])
55
+
56
+ const editorFilteredChats = useMemo(() => {
57
+ if (!enabledEditors) return chats
58
+ return chats.filter(c => enabledEditors.has(c.source))
59
+ }, [chats, enabledEditors])
60
+
61
+ const filteredChats = useMemo(() => {
62
+ if (!chatSearch) return editorFilteredChats
63
+ const q = chatSearch.toLowerCase()
64
+ return editorFilteredChats.filter(c =>
65
+ (c.name && c.name.toLowerCase().includes(q)) ||
66
+ (c.topModel && c.topModel.toLowerCase().includes(q)) ||
67
+ (c.source && c.source.toLowerCase().includes(q))
68
+ )
69
+ }, [editorFilteredChats, chatSearch])
70
+
71
+ const toggleEditor = (editorId) => {
72
+ setEnabledEditors(prev => {
73
+ const next = new Set(prev)
74
+ if (next.has(editorId)) next.delete(editorId)
75
+ else next.add(editorId)
76
+ return next
77
+ })
78
+ }
79
+
80
+ if (!folder) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text3)' }}>no project specified</div>
81
+ if (loading) return <AnimatedLoader label="Loading project..." />
82
+ if (!project) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text3)' }}>project not found</div>
83
+
84
+ const editorEntries = Object.entries(project.editors).sort((a, b) => b[1] - a[1])
85
+ const allEnabled = !enabledEditors || enabledEditors.size === editorEntries.length
86
+
87
+ // Derive stats from editor-filtered chats
88
+ const fSessionCount = editorFilteredChats.length
89
+ const fEditorCounts = {}
90
+ const fModelCounts = {}
91
+ for (const c of editorFilteredChats) {
92
+ fEditorCounts[c.source] = (fEditorCounts[c.source] || 0) + 1
93
+ if (c.topModel) fModelCounts[c.topModel] = (fModelCounts[c.topModel] || 0) + 1
94
+ }
95
+ const fTopModels = Object.entries(fModelCounts).sort((a, b) => b[1] - a[1]).slice(0, 10)
96
+ const fMaxEditorCount = Math.max(...Object.values(fEditorCounts), 1)
97
+
98
+ // Use project-level stats when all editors enabled, otherwise show filtered session count
99
+ const totalTok = project.totalInputTokens + project.totalOutputTokens
100
+ const outputRatio = project.totalInputTokens > 0 ? (project.totalOutputTokens / project.totalInputTokens).toFixed(1) : '0'
101
+ const displaySessions = allEnabled ? project.totalSessions : fSessionCount
102
+ const avgMsgs = allEnabled && project.totalSessions > 0 ? (project.totalMessages / project.totalSessions).toFixed(1) : 0
103
+
104
+ return (
105
+ <div className="fade-in space-y-3">
106
+ {/* Header card */}
107
+ <div className="card p-4">
108
+ <div className="flex items-start gap-3">
109
+ <button
110
+ onClick={() => navigate('/projects')}
111
+ className="flex items-center gap-1 text-[12px] transition mt-0.5 flex-shrink-0"
112
+ style={{ color: 'var(--c-text3)' }}
113
+ >
114
+ <ArrowLeft size={12} />
115
+ </button>
116
+ <FolderOpen size={18} className="flex-shrink-0 mt-0.5" style={{ color: 'var(--c-accent)' }} />
117
+ <div className="flex-1 min-w-0">
118
+ <h1 className="text-sm font-bold truncate" style={{ color: 'var(--c-white)' }}>{project.name}</h1>
119
+ <div className="text-[11px] truncate" style={{ color: 'var(--c-text3)' }}>{project.folder}</div>
120
+ </div>
121
+ <div className="flex items-center gap-1.5 flex-shrink-0">
122
+ {editorEntries.map(([e]) => (
123
+ <EditorIcon key={e} source={e} size={14} />
124
+ ))}
125
+ </div>
126
+ </div>
127
+ <div className="flex items-center gap-4 mt-3 pt-3 text-[11px]" style={{ borderTop: '1px solid var(--c-border)' }}>
128
+ <div className="flex items-center gap-1" style={{ color: 'var(--c-text3)' }}>
129
+ <Calendar size={9} />
130
+ <span>{formatDate(project.firstSeen)}</span>
131
+ <span>→</span>
132
+ <span>{formatDate(project.lastSeen)}</span>
133
+ </div>
134
+ <div className="flex items-center gap-1.5 ml-auto">
135
+ {editorEntries.map(([e, c]) => (
136
+ <span key={e} className="inline-flex items-center gap-0.5">
137
+ <span className="w-1.5 h-1.5 rounded-full" style={{ background: editorColor(e) }} />
138
+ <span style={{ color: 'var(--c-text3)' }}>{editorLabel(e)}</span>
139
+ <span className="font-bold" style={{ color: 'var(--c-text2)' }}>{c}</span>
140
+ </span>
141
+ ))}
142
+ </div>
143
+ </div>
144
+ </div>
145
+
146
+ {/* KPIs */}
147
+ <div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(90px, 1fr))' }}>
148
+ <KpiCard label="sessions" value={displaySessions} sub={!allEnabled ? 'filtered' : ''} />
149
+ <KpiCard label="messages" value={formatNumber(project.totalMessages)} sub={`${avgMsgs} avg/session`} />
150
+ <KpiCard label="tool calls" value={formatNumber(project.totalToolCalls)} sub={<span className="flex items-center gap-0.5"><Wrench size={8} /> invocations</span>} />
151
+ <KpiCard label="tokens" value={formatNumber(totalTok)} sub={`${outputRatio}\u00d7 out/in`} />
152
+ {project.totalCacheRead > 0 && (
153
+ <KpiCard label="cache read" value={formatNumber(project.totalCacheRead)} sub={`write: ${formatNumber(project.totalCacheWrite)}`} />
154
+ )}
155
+ <KpiCard label="you wrote" value={formatNumber(project.totalUserChars)} sub={`AI: ${formatNumber(project.totalAssistantChars)}`} />
156
+ <KpiCard label="est. cost" value={costs && costs.totalCost > 0 ? formatCost(costs.totalCost) : '\u2014'} sub={costs && costs.byModel.length > 0 ? `${costs.byModel.length} model${costs.byModel.length !== 1 ? 's' : ''}` : undefined} />
157
+ </div>
158
+
159
+ {/* Charts row */}
160
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
161
+ {/* Editors bar with checkboxes */}
162
+ <div className="card p-3">
163
+ <SectionTitle>editors</SectionTitle>
164
+ <div className="space-y-1.5 mt-1">
165
+ {editorEntries.map(([e, c]) => {
166
+ const checked = enabledEditors ? enabledEditors.has(e) : true
167
+ const count = fEditorCounts[e] || 0
168
+ return (
169
+ <div key={e} className="flex items-center gap-2">
170
+ <input
171
+ type="checkbox"
172
+ checked={checked}
173
+ onChange={() => toggleEditor(e)}
174
+ className="accent-[var(--c-accent)] w-3 h-3 flex-shrink-0 cursor-pointer"
175
+ />
176
+ <EditorIcon source={e} size={11} />
177
+ <span className="text-[11px] truncate flex-1 cursor-pointer select-none" onClick={() => toggleEditor(e)} style={{ color: checked ? 'var(--c-text2)' : 'var(--c-text3)', opacity: checked ? 1 : 0.4 }}>{editorLabel(e)}</span>
178
+ <div className="w-16 h-3 rounded-sm overflow-hidden" style={{ background: 'var(--c-code-bg)' }}>
179
+ <div className="h-full rounded-sm transition-all" style={{ width: `${(count / fMaxEditorCount * 100).toFixed(0)}%`, background: checked ? editorColor(e) : 'var(--c-text3)', opacity: checked ? 1 : 0.2 }} />
180
+ </div>
181
+ <span className="text-[11px] w-6 text-right font-bold" style={{ color: checked ? 'var(--c-white)' : 'var(--c-text3)', opacity: checked ? 1 : 0.4 }}>{count}</span>
182
+ </div>
183
+ )
184
+ })}
185
+ </div>
186
+ </div>
187
+
188
+ {/* Models doughnut */}
189
+ <div className="card p-3">
190
+ <SectionTitle>models</SectionTitle>
191
+ {fTopModels.length > 0 ? (
192
+ <div style={{ height: 160 }}>
193
+ <Doughnut
194
+ data={{
195
+ labels: fTopModels.map(m => m[0]),
196
+ datasets: [{ data: fTopModels.map(m => m[1]), backgroundColor: MODEL_COLORS, borderWidth: 0 }],
197
+ }}
198
+ options={{
199
+ responsive: true, maintainAspectRatio: false, cutout: '60%',
200
+ plugins: {
201
+ legend: { position: 'right', labels: { color: txtColor, font: { size: 8, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
202
+ tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
203
+ },
204
+ }}
205
+ />
206
+ </div>
207
+ ) : <div className="text-[11px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no model data</div>}
208
+ </div>
209
+
210
+ {/* Top tools horizontal bar */}
211
+ <div className="card p-3">
212
+ <SectionTitle>top tools <span style={{ color: 'var(--c-text3)' }}>({formatNumber(project.totalToolCalls)})</span></SectionTitle>
213
+ {project.topTools.length > 0 ? (
214
+ <div style={{ height: 160 }}>
215
+ <Bar
216
+ data={{
217
+ labels: project.topTools.map(t => t.name),
218
+ datasets: [{
219
+ data: project.topTools.map(t => t.count),
220
+ backgroundColor: TOOL_COLORS.slice(0, project.topTools.length),
221
+ borderRadius: 2,
222
+ }],
223
+ }}
224
+ options={{
225
+ responsive: true, maintainAspectRatio: false, indexAxis: 'y',
226
+ scales: {
227
+ x: { grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 8, family: MONO } } },
228
+ y: { grid: { display: false }, ticks: { color: txtColor, font: { size: 8, family: MONO } } },
229
+ },
230
+ plugins: { legend: { display: false }, tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } } },
231
+ }}
232
+ />
233
+ </div>
234
+ ) : <div className="text-[11px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no tool data</div>}
235
+ </div>
236
+ </div>
237
+
238
+ {/* AI Readiness Audit */}
239
+ {console.log('Rendering AiAuditCard, folder:', folder)}
240
+ <AiAuditCard folder={folder} />
241
+
242
+ {/* Sessions */}
243
+ <div>
244
+ <div className="flex items-center gap-3 mb-2">
245
+ <SectionTitle>sessions <span style={{ color: 'var(--c-text3)' }}>({filteredChats.length}{!allEnabled ? ` of ${chats.length}` : ''})</span></SectionTitle>
246
+ <div className="relative max-w-xs flex-1">
247
+ <Search size={11} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
248
+ <input
249
+ type="text"
250
+ placeholder="filter sessions..."
251
+ value={chatSearch}
252
+ onChange={e => setChatSearch(e.target.value)}
253
+ className="w-full pl-7 pr-3 py-1 text-[12px] outline-none rounded-sm"
254
+ style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
255
+ />
256
+ </div>
257
+ </div>
258
+
259
+ <div className="card overflow-hidden">
260
+ <table className="w-full text-[12px]">
261
+ <thead>
262
+ <tr className="text-[10px] uppercase tracking-wider" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text3)' }}>
263
+ <th className="text-left py-2 px-3 font-medium">editor</th>
264
+ <th className="text-left py-2 px-3 font-medium">name</th>
265
+ <th className="text-left py-2 px-3 font-medium">mode</th>
266
+ <th className="text-left py-2 px-3 font-medium">model</th>
267
+ <th className="text-left py-2 px-3 font-medium">context</th>
268
+ <th className="text-right py-2 px-3 font-medium">est. cost</th>
269
+ <th className="text-left py-2 px-3 font-medium">updated</th>
270
+ </tr>
271
+ </thead>
272
+ <tbody>
273
+ {filteredChats.map(c => (
274
+ <tr
275
+ key={c.id}
276
+ className="cursor-pointer transition"
277
+ style={{ borderBottom: '1px solid var(--c-border)' }}
278
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg3)'}
279
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
280
+ onClick={() => setSelectedChatId(c.id)}
281
+ >
282
+ <td className="py-2 px-3">
283
+ <span className="inline-flex items-center gap-1.5">
284
+ <EditorIcon source={c.source} size={12} />
285
+ <span style={{ color: 'var(--c-text2)' }}>{editorLabel(c.source)}</span>
286
+ </span>
287
+ </td>
288
+ <td className="py-2 px-3 font-medium truncate max-w-[280px]" style={{ color: 'var(--c-white)' }}>
289
+ {c.name || <span style={{ color: 'var(--c-text3)' }}>Untitled</span>}
290
+ {c.encrypted && <span className="ml-1.5 text-[10px] text-yellow-500/60">locked</span>}
291
+ </td>
292
+ <td className="py-2 px-3" style={{ color: 'var(--c-text2)' }}>{c.mode || ''}</td>
293
+ <td className="py-2 px-3 font-mono truncate max-w-[150px]" style={{ color: 'var(--c-text2)' }} title={c.topModel || ''}>{c.topModel || ''}</td>
294
+ <td className="py-2 px-3">
295
+ {c.bubbleCount >= 500 ? (
296
+ <span className="inline-flex items-center gap-0.5 font-bold" style={{ color: '#ef4444' }}>
297
+ <AlertTriangle size={9} />{c.bubbleCount} msgs
298
+ </span>
299
+ ) : c.bubbleCount >= 100 ? (
300
+ <span className="inline-flex items-center gap-0.5 font-bold" style={{ color: '#f59e0b' }}>
301
+ <AlertTriangle size={9} />{c.bubbleCount} msgs
302
+ </span>
303
+ ) : (
304
+ <span style={{ color: 'var(--c-text3)' }}>{c.bubbleCount || 0} msgs</span>
305
+ )}
306
+ </td>
307
+ <td className="py-2 px-3 font-mono text-right" style={{ color: c.cost > 0 ? 'var(--c-text2)' : 'var(--c-text3)' }}>
308
+ {c.cost > 0 ? formatCost(c.cost) : ''}
309
+ </td>
310
+ <td className="py-2 px-3 whitespace-nowrap" style={{ color: 'var(--c-text3)' }}>{formatDate(c.lastUpdatedAt || c.createdAt)}</td>
311
+ </tr>
312
+ ))}
313
+ </tbody>
314
+ </table>
315
+ {filteredChats.length === 0 && (
316
+ <div className="text-center py-8 text-sm" style={{ color: 'var(--c-text3)' }}>no sessions found</div>
317
+ )}
318
+ </div>
319
+ </div>
320
+
321
+ {/* Chat sidebar */}
322
+ <ChatSidebar chatId={selectedChatId} onClose={() => setSelectedChatId(null)} />
323
+ </div>
324
+ )
325
+ }
@@ -0,0 +1,249 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement } from 'chart.js'
3
+ import { Doughnut, Bar } from 'react-chartjs-2'
4
+ import { Search, MessageSquare, Wrench, Cpu, FolderOpen, Calendar } from 'lucide-react'
5
+ import { useNavigate } from 'react-router-dom'
6
+ import { fetchProjects, fetchCosts } 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 DateRangePicker from '../components/DateRangePicker'
12
+ import SectionTitle from '../components/SectionTitle'
13
+ import AnimatedLoader from '../components/AnimatedLoader'
14
+ import PageHeader from '../components/PageHeader'
15
+
16
+ ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement)
17
+
18
+ const MONO = 'JetBrains Mono, monospace'
19
+ const MODEL_COLORS = ['#6366f1', '#a78bfa', '#818cf8', '#c084fc', '#e879f9', '#f472b6', '#fb7185', '#f87171', '#fbbf24', '#34d399']
20
+
21
+ export default function Projects({ overview }) {
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
+ const [projects, setProjects] = useState(null)
27
+ const [search, setSearch] = useState('')
28
+ const [editorFilter, setEditorFilter] = useState('')
29
+ const [dateRange, setDateRange] = useState(null)
30
+ const [costs, setCosts] = useState(null)
31
+ const navigate = useNavigate()
32
+ const editors = overview?.editors || []
33
+
34
+ useEffect(() => {
35
+ const dateParams = dateRangeToApiParams(dateRange)
36
+ Promise.all([
37
+ fetchProjects(dateParams),
38
+ fetchCosts(dateParams),
39
+ ]).then(([p, c]) => { setProjects(p); setCosts(c) })
40
+ }, [dateRange])
41
+
42
+ if (!projects) return <AnimatedLoader label="Loading projects..." />
43
+
44
+ const filtered = projects.filter(p => {
45
+ if (editorFilter && !p.editors[editorFilter]) return false
46
+ if (search && !p.folder.toLowerCase().includes(search.toLowerCase()) && !p.name.toLowerCase().includes(search.toLowerCase())) return false
47
+ return true
48
+ })
49
+
50
+ const totalSessions = projects.reduce((s, p) => s + p.totalSessions, 0)
51
+ const totalMessages = projects.reduce((s, p) => s + p.totalMessages, 0)
52
+ const totalTokens = projects.reduce((s, p) => s + p.totalInputTokens + p.totalOutputTokens, 0)
53
+ const maxSessions = Math.max(...projects.map(p => p.totalSessions), 1)
54
+
55
+ // Aggregate for charts
56
+ const globalModels = {}
57
+ const globalEditors = {}
58
+ for (const p of projects) {
59
+ for (const m of p.topModels) globalModels[m.name] = (globalModels[m.name] || 0) + m.count
60
+ for (const [e, c] of Object.entries(p.editors)) globalEditors[e] = (globalEditors[e] || 0) + c
61
+ }
62
+ const topGlobalModels = Object.entries(globalModels).sort((a, b) => b[1] - a[1]).slice(0, 8)
63
+ const topGlobalEditors = Object.entries(globalEditors).sort((a, b) => b[1] - a[1])
64
+ const top10 = projects.slice(0, 10)
65
+
66
+ return (
67
+ <div className="fade-in space-y-3">
68
+ <PageHeader icon={FolderOpen} title="Projects" />
69
+
70
+ {/* KPIs */}
71
+ <div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(90px, 1fr))' }}>
72
+ <KpiCard label="projects" value={projects.length} />
73
+ <KpiCard label="sessions" value={formatNumber(totalSessions)} onClick={() => navigate('/sessions')} />
74
+ <KpiCard label="messages" value={formatNumber(totalMessages)} />
75
+ <KpiCard label="tokens" value={formatNumber(totalTokens)} />
76
+ <KpiCard label="est. cost" value={costs && costs.totalCost > 0 ? formatCost(costs.totalCost) : '\u2014'} />
77
+ </div>
78
+
79
+ {/* Charts row */}
80
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
81
+ <div className="card p-3">
82
+ <SectionTitle>top projects <span style={{ color: 'var(--c-text3)' }}>by sessions</span></SectionTitle>
83
+ <div style={{ height: 160 }}>
84
+ <Bar
85
+ data={{
86
+ labels: top10.map(p => p.name),
87
+ datasets: [{
88
+ data: top10.map(p => p.totalSessions),
89
+ backgroundColor: '#6366f1',
90
+ borderRadius: 3,
91
+ }],
92
+ }}
93
+ options={{
94
+ responsive: true, maintainAspectRatio: false, indexAxis: 'y',
95
+ scales: {
96
+ x: { grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 8, family: MONO } } },
97
+ y: { grid: { display: false }, ticks: { color: txtColor, font: { size: 8, family: MONO }, callback: (v, i) => top10[i]?.name?.length > 16 ? top10[i].name.slice(0, 15) + '…' : top10[i]?.name } },
98
+ },
99
+ plugins: { legend: { display: false }, tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } } },
100
+ }}
101
+ />
102
+ </div>
103
+ </div>
104
+ <div className="card p-3">
105
+ <SectionTitle>editors</SectionTitle>
106
+ <div style={{ height: 160 }}>
107
+ <Doughnut
108
+ data={{
109
+ labels: topGlobalEditors.map(e => editorLabel(e[0])),
110
+ datasets: [{ data: topGlobalEditors.map(e => e[1]), backgroundColor: topGlobalEditors.map(e => editorColor(e[0])), borderWidth: 0 }],
111
+ }}
112
+ options={{
113
+ responsive: true, maintainAspectRatio: false, cutout: '60%',
114
+ plugins: {
115
+ legend: { position: 'right', labels: { color: txtColor, font: { size: 9, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
116
+ tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
117
+ },
118
+ }}
119
+ />
120
+ </div>
121
+ </div>
122
+ <div className="card p-3">
123
+ <SectionTitle>models</SectionTitle>
124
+ <div style={{ height: 160 }}>
125
+ {topGlobalModels.length > 0 ? (
126
+ <Doughnut
127
+ data={{
128
+ labels: topGlobalModels.map(m => m[0]),
129
+ datasets: [{ data: topGlobalModels.map(m => m[1]), backgroundColor: MODEL_COLORS, borderWidth: 0 }],
130
+ }}
131
+ options={{
132
+ responsive: true, maintainAspectRatio: false, cutout: '60%',
133
+ plugins: {
134
+ legend: { position: 'right', labels: { color: txtColor, font: { size: 9, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
135
+ tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
136
+ },
137
+ }}
138
+ />
139
+ ) : <div className="text-xs text-center py-12" style={{ color: 'var(--c-text3)' }}>no model data</div>}
140
+ </div>
141
+ </div>
142
+ </div>
143
+
144
+ {/* Filters */}
145
+ <div className="flex items-center gap-2">
146
+ <select
147
+ value={editorFilter}
148
+ onChange={e => setEditorFilter(e.target.value)}
149
+ className="px-2 py-1 text-[12px] outline-none"
150
+ style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
151
+ >
152
+ <option value="">All Editors</option>
153
+ {editors.map(e => (
154
+ <option key={e.id} value={e.id}>{editorLabel(e.id)}</option>
155
+ ))}
156
+ </select>
157
+ <div className="relative flex-1 max-w-sm">
158
+ <Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
159
+ <input
160
+ type="text"
161
+ placeholder="Search projects..."
162
+ value={search}
163
+ onChange={e => setSearch(e.target.value)}
164
+ className="w-full pl-7 pr-3 py-1 text-[12px] outline-none"
165
+ style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
166
+ />
167
+ </div>
168
+ <div className="ml-auto"><DateRangePicker value={dateRange} onChange={setDateRange} /></div>
169
+ </div>
170
+
171
+ {/* Project cards */}
172
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
173
+ {filtered.map(p => {
174
+ const editorEntries = Object.entries(p.editors).sort((a, b) => b[1] - a[1])
175
+ const totalTok = p.totalInputTokens + p.totalOutputTokens
176
+ const topModel = p.topModels[0]
177
+
178
+ return (
179
+ <div
180
+ key={p.folder}
181
+ className="card p-3 cursor-pointer transition hover:opacity-90 flex flex-col gap-2.5"
182
+ onClick={() => navigate(`/projects/detail?folder=${encodeURIComponent(p.folder)}`)}
183
+ >
184
+ {/* Header: name + editors */}
185
+ <div className="flex items-start gap-2 min-w-0">
186
+ <FolderOpen size={14} className="flex-shrink-0 mt-0.5" style={{ color: 'var(--c-accent)' }} />
187
+ <div className="flex-1 min-w-0">
188
+ <div className="text-[12px] font-bold truncate" style={{ color: 'var(--c-white)' }}>{p.name}</div>
189
+ <div className="text-[10px] truncate" style={{ color: 'var(--c-text3)' }}>{p.folder}</div>
190
+ </div>
191
+ <div className="flex items-center gap-1 flex-shrink-0">
192
+ {editorEntries.slice(0, 4).map(([e]) => (
193
+ <EditorIcon key={e} source={e} size={12} />
194
+ ))}
195
+ </div>
196
+ </div>
197
+
198
+ {/* Activity bar */}
199
+ <div className="w-full h-1 rounded-full overflow-hidden" style={{ background: 'var(--c-code-bg)' }}>
200
+ <div className="h-full rounded-full" style={{ width: `${(p.totalSessions / maxSessions * 100).toFixed(1)}%`, background: 'var(--c-accent)' }} />
201
+ </div>
202
+
203
+ {/* Stats grid */}
204
+ <div className="grid grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
205
+ <div className="flex items-center gap-1">
206
+ <MessageSquare size={9} style={{ color: 'var(--c-text3)' }} />
207
+ <span style={{ color: 'var(--c-text2)' }}>{p.totalSessions} sessions</span>
208
+ <span className="ml-auto font-bold" style={{ color: 'var(--c-white)' }}>{formatNumber(p.totalMessages)} msgs</span>
209
+ </div>
210
+ <div className="flex items-center gap-1">
211
+ <Wrench size={9} style={{ color: 'var(--c-text3)' }} />
212
+ <span style={{ color: 'var(--c-text2)' }}>tools</span>
213
+ <span className="ml-auto font-bold" style={{ color: 'var(--c-white)' }}>{formatNumber(p.totalToolCalls)}</span>
214
+ </div>
215
+ <div className="flex items-center gap-1">
216
+ <Cpu size={9} style={{ color: 'var(--c-text3)' }} />
217
+ <span className="truncate" style={{ color: 'var(--c-text2)' }}>{topModel ? topModel.name : '—'}</span>
218
+ </div>
219
+ <div className="flex items-center gap-1">
220
+ <span style={{ color: 'var(--c-text3)' }}>tok</span>
221
+ <span className="ml-auto font-bold" style={{ color: totalTok > 0 ? 'var(--c-white)' : 'var(--c-text3)' }}>{totalTok > 0 ? formatNumber(totalTok) : '—'}</span>
222
+ </div>
223
+ </div>
224
+
225
+ {/* Footer: editors breakdown + date */}
226
+ <div className="flex items-center gap-2 pt-1 text-[10px]" style={{ borderTop: '1px solid var(--c-border)' }}>
227
+ <div className="flex items-center gap-1.5 flex-1 min-w-0">
228
+ {editorEntries.map(([e, c]) => (
229
+ <span key={e} className="inline-flex items-center gap-0.5 truncate">
230
+ <span className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: editorColor(e) }} />
231
+ <span style={{ color: 'var(--c-text3)' }}>{editorLabel(e)}</span>
232
+ <span className="font-bold" style={{ color: 'var(--c-text2)' }}>{c}</span>
233
+ </span>
234
+ ))}
235
+ </div>
236
+ <div className="flex items-center gap-1 flex-shrink-0" style={{ color: 'var(--c-text3)' }}>
237
+ <Calendar size={8} />
238
+ <span>{formatDate(p.lastSeen)}</span>
239
+ </div>
240
+ </div>
241
+ </div>
242
+ )
243
+ })}
244
+ </div>
245
+
246
+ {filtered.length === 0 && <div className="text-center py-12 text-sm" style={{ color: 'var(--c-text3)' }}>no projects found</div>}
247
+ </div>
248
+ )
249
+ }