memhouse 0.0.1 → 0.3.1

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 (97) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +72 -3
  3. package/bin/memhouse.js +559 -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/adapter-errors.js +52 -0
  9. package/editors/antigravity.js +1048 -0
  10. package/editors/base.js +321 -0
  11. package/editors/claude.js +386 -0
  12. package/editors/codebuff.js +338 -0
  13. package/editors/codex.js +537 -0
  14. package/editors/commandcode.js +166 -0
  15. package/editors/copilot-jetbrains.js +207 -0
  16. package/editors/copilot.js +260 -0
  17. package/editors/cursor-agent.js +202 -0
  18. package/editors/cursor.js +498 -0
  19. package/editors/gemini.js +231 -0
  20. package/editors/goose.js +386 -0
  21. package/editors/gsd.js +366 -0
  22. package/editors/index.js +274 -0
  23. package/editors/kiro.js +315 -0
  24. package/editors/opencode.js +432 -0
  25. package/editors/vscode.js +413 -0
  26. package/editors/windsurf.js +634 -0
  27. package/editors/zed.js +229 -0
  28. package/index.js +424 -2
  29. package/mcp-server.js +294 -0
  30. package/mem-house/DESIGN.md +119 -0
  31. package/mem-house/delivery/AGENT-INSTALL.md +87 -0
  32. package/mem-house/delivery/PROMPT.md +42 -0
  33. package/mem-house/delivery/install.sh +181 -0
  34. package/mem-house/delivery/kernel-install.md +83 -0
  35. package/mem-house/delivery/plugin/.claude-plugin/plugin.json +8 -0
  36. package/mem-house/delivery/skills/memhouse-search/SKILL.md +104 -0
  37. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +94 -0
  38. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +115 -0
  39. package/mem-house/rls.sql +17 -0
  40. package/mem-house/schema.sql +145 -0
  41. package/mem-house/server/queries.js +878 -0
  42. package/mem-house/server/server.js +228 -0
  43. package/mem-house/shipper/ship.js +461 -0
  44. package/misc/syntax-check.js +38 -0
  45. package/package.json +59 -7
  46. package/pricing.js +88 -0
  47. package/pricing.json +992 -0
  48. package/public/assets/index-CCPh7a2Z.js +75 -0
  49. package/public/assets/index-DV6ONi_F.css +2 -0
  50. package/public/index.html +16 -0
  51. package/relay-client.js +306 -0
  52. package/relay-server.js +577 -0
  53. package/server.js +860 -0
  54. package/share-image.js +367 -0
  55. package/ui/eslint.config.js +29 -0
  56. package/ui/index.html +15 -0
  57. package/ui/package-lock.json +4229 -0
  58. package/ui/package.json +38 -0
  59. package/ui/src/App.jsx +364 -0
  60. package/ui/src/components/ActivityHeatmap.jsx +206 -0
  61. package/ui/src/components/AiAuditCard.jsx +254 -0
  62. package/ui/src/components/AnimatedLoader.jsx +14 -0
  63. package/ui/src/components/AnimatedLogo.jsx +96 -0
  64. package/ui/src/components/ChatSidebar.jsx +226 -0
  65. package/ui/src/components/DateRangePicker.jsx +104 -0
  66. package/ui/src/components/EditorDot.jsx +11 -0
  67. package/ui/src/components/EditorIcon.jsx +117 -0
  68. package/ui/src/components/KpiCard.jsx +9 -0
  69. package/ui/src/components/LiveFeed.jsx +128 -0
  70. package/ui/src/components/LoginScreen.jsx +81 -0
  71. package/ui/src/components/MessageRenderer.jsx +148 -0
  72. package/ui/src/components/PageHeader.jsx +11 -0
  73. package/ui/src/components/SectionTitle.jsx +3 -0
  74. package/ui/src/components/ShareModal.jsx +305 -0
  75. package/ui/src/components/TokenTimeline.jsx +258 -0
  76. package/ui/src/hooks/useLive.jsx +15 -0
  77. package/ui/src/index.css +83 -0
  78. package/ui/src/lib/api.js +343 -0
  79. package/ui/src/lib/constants.js +88 -0
  80. package/ui/src/lib/theme.jsx +23 -0
  81. package/ui/src/main.jsx +16 -0
  82. package/ui/src/pages/Artifacts.jsx +575 -0
  83. package/ui/src/pages/Compare.jsx +232 -0
  84. package/ui/src/pages/CostAnalysis.jsx +354 -0
  85. package/ui/src/pages/Dashboard.jsx +529 -0
  86. package/ui/src/pages/DeepAnalysis.jsx +495 -0
  87. package/ui/src/pages/GSD.jsx +726 -0
  88. package/ui/src/pages/MCPs.jsx +744 -0
  89. package/ui/src/pages/ProjectDetail.jsx +325 -0
  90. package/ui/src/pages/Projects.jsx +249 -0
  91. package/ui/src/pages/RelayDashboard.jsx +590 -0
  92. package/ui/src/pages/RelayUserDetail.jsx +466 -0
  93. package/ui/src/pages/Sessions.jsx +535 -0
  94. package/ui/src/pages/Settings.jsx +241 -0
  95. package/ui/src/pages/SqlViewer.jsx +370 -0
  96. package/ui/src/pages/Subscriptions.jsx +411 -0
  97. package/ui/vite.config.js +18 -0
@@ -0,0 +1,575 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { FolderOpen, FileText, ChevronRight, ChevronDown, Search, Package, Clock, Hash, X, Code, Eye, DollarSign, Type } from 'lucide-react'
3
+ import ReactMarkdown from 'react-markdown'
4
+ import remarkGfm from 'remark-gfm'
5
+ import { fetchArtifacts, fetchArtifactContent } from '../lib/api'
6
+ import { editorColor, editorLabel } from '../lib/constants'
7
+ import EditorIcon from '../components/EditorIcon'
8
+ import AnimatedLoader from '../components/AnimatedLoader'
9
+ import PageHeader from '../components/PageHeader'
10
+
11
+ const MONO = 'JetBrains Mono, monospace'
12
+
13
+ function formatSize(bytes) {
14
+ if (bytes < 1024) return bytes + ' B'
15
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
16
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
17
+ }
18
+
19
+ function formatDate(ts) {
20
+ if (!ts) return ''
21
+ return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })
22
+ }
23
+
24
+ function estimateTokens(text) {
25
+ if (!text) return 0
26
+ // ~4 chars per token is a reasonable approximation
27
+ return Math.round(text.length / 4)
28
+ }
29
+
30
+ function formatTokens(n) {
31
+ if (n < 1000) return String(n)
32
+ if (n < 1000000) return (n / 1000).toFixed(1) + 'k'
33
+ return (n / 1000000).toFixed(2) + 'M'
34
+ }
35
+
36
+ // Rough est. based on average input token pricing (~$3/1M tokens)
37
+ function estimateCost(tokens) {
38
+ const cost = (tokens / 1000000) * 3
39
+ if (cost < 0.001) return '<$0.001'
40
+ if (cost < 0.01) return '$' + cost.toFixed(4)
41
+ return '$' + cost.toFixed(3)
42
+ }
43
+
44
+ function getFileType(fileName) {
45
+ if (!fileName) return 'text'
46
+ const lower = fileName.toLowerCase()
47
+ if (lower.endsWith('.json')) return 'json'
48
+ if (lower.endsWith('.md') || lower.endsWith('.mdc')) return 'markdown'
49
+ if (lower.endsWith('.yaml') || lower.endsWith('.yml')) return 'yaml'
50
+ return 'text'
51
+ }
52
+
53
+ function JsonViewer({ content }) {
54
+ let parsed
55
+ try {
56
+ parsed = JSON.parse(content)
57
+ } catch {
58
+ return <pre className="text-[12px] whitespace-pre-wrap break-words leading-relaxed" style={{ color: '#ef4444', fontFamily: MONO }}>Invalid JSON</pre>
59
+ }
60
+
61
+ const formatted = JSON.stringify(parsed, null, 2)
62
+
63
+ return (
64
+ <pre className="text-[12px] whitespace-pre-wrap break-words leading-relaxed" style={{ fontFamily: MONO }}>
65
+ {formatted.split('\n').map((line, i) => {
66
+ const parts = []
67
+ let remaining = line
68
+
69
+ // Highlight JSON keys
70
+ const keyMatch = remaining.match(/^(\s*)"([^"]+)"(:)/)
71
+ if (keyMatch) {
72
+ parts.push(<span key={`i${i}`} style={{ color: 'var(--c-text3)' }}>{keyMatch[1]}</span>)
73
+ parts.push(<span key={`k${i}`} style={{ color: '#818cf8' }}>"{keyMatch[2]}"</span>)
74
+ parts.push(<span key={`c${i}`} style={{ color: 'var(--c-text3)' }}>:</span>)
75
+ remaining = remaining.slice(keyMatch[0].length)
76
+ }
77
+
78
+ // Highlight values
79
+ const strMatch = remaining.match(/^(\s*)"([^"]*)"(.*)/)
80
+ if (strMatch) {
81
+ parts.push(<span key={`sp${i}`}>{strMatch[1]}</span>)
82
+ parts.push(<span key={`v${i}`} style={{ color: '#22c55e' }}>"{strMatch[2]}"</span>)
83
+ parts.push(<span key={`r${i}`} style={{ color: 'var(--c-text3)' }}>{strMatch[3]}</span>)
84
+ } else {
85
+ // Numbers, booleans, null
86
+ const valMatch = remaining.match(/^(\s*)(true|false|null|-?\d+\.?\d*)(.*)?/)
87
+ if (valMatch) {
88
+ parts.push(<span key={`sp${i}`}>{valMatch[1]}</span>)
89
+ parts.push(<span key={`v${i}`} style={{ color: valMatch[2] === 'null' ? '#ef4444' : valMatch[2] === 'true' || valMatch[2] === 'false' ? '#f59e0b' : '#22d3ee' }}>{valMatch[2]}</span>)
90
+ parts.push(<span key={`r${i}`} style={{ color: 'var(--c-text3)' }}>{valMatch[3] || ''}</span>)
91
+ } else {
92
+ parts.push(<span key={`x${i}`} style={{ color: 'var(--c-text3)' }}>{remaining}</span>)
93
+ }
94
+ }
95
+
96
+ return <span key={i}>{parts}{'\n'}</span>
97
+ })}
98
+ </pre>
99
+ )
100
+ }
101
+
102
+ function parseFrontmatter(raw) {
103
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
104
+ if (!match) return { frontmatter: null, body: raw }
105
+ const entries = []
106
+ const lines = match[1].split('\n')
107
+ for (let i = 0; i < lines.length; i++) {
108
+ const kv = lines[i].match(/^(\w[\w-]*):\s*(.*)/)
109
+ if (kv) {
110
+ const key = kv[1]
111
+ const inlineVal = kv[2].replace(/^["']|["']$/g, '').trim()
112
+ if (inlineVal) {
113
+ entries.push([key, inlineVal])
114
+ } else {
115
+ // Collect list items (could be simple strings, checkboxes, or YAML objects)
116
+ const items = []
117
+ while (i + 1 < lines.length && /^\s+-\s/.test(lines[i + 1])) {
118
+ i++
119
+ const item = lines[i].replace(/^\s+-\s*/, '')
120
+ const checkMatch = item.match(/^\[([ xX])\]\s*(.*)/)
121
+ if (checkMatch) {
122
+ items.push({ type: 'checkbox', checked: checkMatch[1] !== ' ', text: checkMatch[2] })
123
+ } else {
124
+ // Could be start of a YAML object (e.g. "id: scaffold-core")
125
+ const objKv = item.match(/^(\w[\w-]*):\s*(.*)/)
126
+ if (objKv) {
127
+ const obj = { [objKv[1]]: objKv[2].replace(/^["']|["']$/g, '') }
128
+ // Collect remaining properties of this object
129
+ while (i + 1 < lines.length && /^\s{4,}\w/.test(lines[i + 1]) && !/^\s+-/.test(lines[i + 1])) {
130
+ i++
131
+ const propMatch = lines[i].trim().match(/^(\w[\w-]*):\s*(.*)/)
132
+ if (propMatch) obj[propMatch[1]] = propMatch[2].replace(/^["']|["']$/g, '')
133
+ }
134
+ items.push({ type: 'object', data: obj })
135
+ } else {
136
+ items.push({ type: 'text', text: item })
137
+ }
138
+ }
139
+ }
140
+ entries.push([key, items.length ? items : ''])
141
+ }
142
+ }
143
+ }
144
+ return { frontmatter: entries.length ? entries : null, body: match[2] }
145
+ }
146
+
147
+ const STATUS_STYLES = {
148
+ completed: { bg: 'rgba(34,197,94,0.12)', color: '#22c55e', icon: '✓' },
149
+ in_progress: { bg: 'rgba(99,102,241,0.12)', color: '#818cf8', icon: '◐' },
150
+ pending: { bg: 'rgba(250,204,21,0.12)', color: '#facc15', icon: '○' },
151
+ blocked: { bg: 'rgba(239,68,68,0.12)', color: '#ef4444', icon: '✕' },
152
+ }
153
+
154
+ function FrontmatterValue({ val }) {
155
+ if (typeof val === 'string') return <span style={{ color: 'var(--c-text2)' }}>{val}</span>
156
+ if (!Array.isArray(val)) return null
157
+
158
+ // Check if items are YAML objects (e.g. todos with id/content/status)
159
+ const hasObjects = val.some(item => item.type === 'object')
160
+ if (hasObjects) {
161
+ return (
162
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4, width: '100%' }}>
163
+ {val.map((item, i) => {
164
+ if (item.type !== 'object') return <span key={i} style={{ color: 'var(--c-text2)' }}>{item.text || ''}</span>
165
+ const d = item.data
166
+ const status = (d.status || '').toLowerCase().replace(/\s+/g, '_')
167
+ const st = STATUS_STYLES[status] || STATUS_STYLES.pending
168
+ const isComplete = status === 'completed'
169
+ return (
170
+ <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 0' }}>
171
+ <span style={{
172
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
173
+ width: 16, height: 16, borderRadius: 3, flexShrink: 0,
174
+ background: st.bg, color: st.color, fontSize: 10, fontWeight: 700,
175
+ }}>{st.icon}</span>
176
+ <span style={{ color: 'var(--c-text2)', opacity: isComplete ? 0.55 : 1, textDecoration: isComplete ? 'line-through' : 'none', flex: 1 }}>
177
+ {d.content || d.title || d.name || d.id || JSON.stringify(d)}
178
+ </span>
179
+ {d.status && (
180
+ <span style={{ fontSize: 10, padding: '1px 6px', borderRadius: 3, background: st.bg, color: st.color, flexShrink: 0 }}>
181
+ {d.status}
182
+ </span>
183
+ )}
184
+ </div>
185
+ )
186
+ })}
187
+ </div>
188
+ )
189
+ }
190
+
191
+ return (
192
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
193
+ {val.map((item, i) => (
194
+ <label key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--c-text2)', cursor: 'default' }}>
195
+ {item.type === 'checkbox' ? (
196
+ <span style={{
197
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
198
+ width: 14, height: 14, borderRadius: 3, flexShrink: 0,
199
+ border: item.checked ? 'none' : '1.5px solid var(--c-text3)',
200
+ background: item.checked ? '#6366f1' : 'transparent',
201
+ color: '#fff', fontSize: 10, lineHeight: 1,
202
+ }}>
203
+ {item.checked ? '✓' : ''}
204
+ </span>
205
+ ) : (
206
+ <span style={{ color: 'var(--c-text3)' }}>•</span>
207
+ )}
208
+ <span style={{ textDecoration: item.checked ? 'line-through' : 'none', opacity: item.checked ? 0.5 : 1 }}>{item.text}</span>
209
+ </label>
210
+ ))}
211
+ </div>
212
+ )
213
+ }
214
+
215
+ function MarkdownViewer({ content }) {
216
+ const { frontmatter, body } = parseFrontmatter(content)
217
+ return (
218
+ <div className="artifact-markdown" style={{ color: 'var(--c-text)', fontSize: 14, lineHeight: 1.7, maxWidth: 720, margin: '0 auto' }}>
219
+ {frontmatter && (
220
+ <div style={{ background: 'var(--c-bg)', border: '1px solid var(--c-border)', borderRadius: 6, padding: '10px 14px', marginBottom: 16, fontSize: 12, fontFamily: MONO }}>
221
+ {frontmatter.map(([key, val], i) => (
222
+ <div key={i} style={{ display: 'flex', gap: 8, padding: '3px 0', alignItems: Array.isArray(val) ? 'flex-start' : 'center' }}>
223
+ <span style={{ color: '#818cf8', minWidth: 100, flexShrink: 0, paddingTop: Array.isArray(val) ? 1 : 0 }}>{key}</span>
224
+ <FrontmatterValue val={val} />
225
+ </div>
226
+ ))}
227
+ </div>
228
+ )}
229
+ <ReactMarkdown
230
+ remarkPlugins={[remarkGfm]}
231
+ components={{
232
+ h1: ({ children }) => <h1 style={{ fontSize: 22, fontWeight: 700, margin: '0 0 12px 0', paddingBottom: 8, borderBottom: '1px solid var(--c-border)', color: 'var(--c-white)' }}>{children}</h1>,
233
+ h2: ({ children }) => <h2 style={{ fontSize: 18, fontWeight: 600, margin: '20px 0 8px 0', paddingBottom: 6, borderBottom: '1px solid var(--c-border)', color: 'var(--c-white)' }}>{children}</h2>,
234
+ h3: ({ children }) => <h3 style={{ fontSize: 15, fontWeight: 600, margin: '16px 0 6px 0', color: 'var(--c-white)' }}>{children}</h3>,
235
+ h4: ({ children }) => <h4 style={{ fontSize: 14, fontWeight: 600, margin: '12px 0 4px 0', color: 'var(--c-white)' }}>{children}</h4>,
236
+ p: ({ children }) => <p style={{ margin: '0 0 10px 0' }}>{children}</p>,
237
+ ul: ({ children }) => <ul style={{ margin: '0 0 10px 0', paddingLeft: 24, listStyleType: 'disc' }}>{children}</ul>,
238
+ ol: ({ children }) => <ol style={{ margin: '0 0 10px 0', paddingLeft: 24, listStyleType: 'decimal' }}>{children}</ol>,
239
+ li: ({ children, className }) => {
240
+ const isTask = className === 'task-list-item'
241
+ return <li style={{ margin: '2px 0', listStyleType: isTask ? 'none' : undefined, marginLeft: isTask ? -20 : 0 }}>{children}</li>
242
+ },
243
+ input: ({ type, checked }) => {
244
+ if (type === 'checkbox') {
245
+ return (
246
+ <span style={{
247
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
248
+ width: 15, height: 15, borderRadius: 3, marginRight: 6, verticalAlign: -2, flexShrink: 0,
249
+ border: checked ? 'none' : '1.5px solid var(--c-text3)',
250
+ background: checked ? '#6366f1' : 'transparent',
251
+ color: '#fff', fontSize: 10,
252
+ }}>{checked ? '✓' : ''}</span>
253
+ )
254
+ }
255
+ return null
256
+ },
257
+ pre: ({ children }) => (
258
+ <pre style={{ background: 'var(--c-bg)', border: '1px solid var(--c-border)', borderRadius: 6, padding: 12, margin: '8px 0 12px 0', overflow: 'auto', fontFamily: MONO, fontSize: 12, color: 'var(--c-text)', lineHeight: 1.6 }}>
259
+ {children}
260
+ </pre>
261
+ ),
262
+ code: ({ node, children }) => {
263
+ const isBlock = node?.position && node.position.start.line !== node.position.end.line
264
+ if (isBlock) return <code>{children}</code>
265
+ return <code style={{ background: 'var(--c-bg3)', padding: '1px 5px', borderRadius: 4, fontFamily: MONO, fontSize: 12, color: '#818cf8' }}>{children}</code>
266
+ },
267
+ blockquote: ({ children }) => (
268
+ <blockquote style={{ borderLeft: '3px solid #6366f1', paddingLeft: 12, margin: '8px 0', color: 'var(--c-text2)', fontStyle: 'italic' }}>{children}</blockquote>
269
+ ),
270
+ table: ({ children }) => (
271
+ <div style={{ overflow: 'auto', margin: '8px 0 12px 0' }}>
272
+ <table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 13 }}>{children}</table>
273
+ </div>
274
+ ),
275
+ thead: ({ children }) => <thead style={{ borderBottom: '2px solid var(--c-border)' }}>{children}</thead>,
276
+ th: ({ children }) => <th style={{ textAlign: 'left', padding: '6px 12px', fontWeight: 600, color: 'var(--c-white)' }}>{children}</th>,
277
+ td: ({ children }) => <td style={{ padding: '5px 12px', borderBottom: '1px solid var(--c-border)' }}>{children}</td>,
278
+ a: ({ href, children }) => <a href={href} target="_blank" rel="noopener noreferrer" style={{ color: '#818cf8', textDecoration: 'underline', textUnderlineOffset: 2 }}>{children}</a>,
279
+ hr: () => <hr style={{ border: 'none', borderTop: '1px solid var(--c-border)', margin: '16px 0' }} />,
280
+ strong: ({ children }) => <strong style={{ color: 'var(--c-white)', fontWeight: 600 }}>{children}</strong>,
281
+ img: ({ src, alt }) => <img src={src} alt={alt} style={{ maxWidth: '100%', borderRadius: 6, margin: '8px 0' }} />,
282
+ }}
283
+ >
284
+ {body}
285
+ </ReactMarkdown>
286
+ </div>
287
+ )
288
+ }
289
+
290
+ function ArtifactContent({ content, fileName, viewRaw }) {
291
+ if (viewRaw) {
292
+ return (
293
+ <pre
294
+ className="text-[12px] whitespace-pre-wrap break-words leading-relaxed"
295
+ style={{ color: 'var(--c-text)', fontFamily: MONO }}
296
+ >{content}</pre>
297
+ )
298
+ }
299
+
300
+ const fileType = getFileType(fileName)
301
+
302
+ if (fileType === 'json') return <JsonViewer content={content} />
303
+ if (fileType === 'markdown') return <MarkdownViewer content={content} />
304
+
305
+ // yaml and other text files — show as raw
306
+ return (
307
+ <pre
308
+ className="text-[12px] whitespace-pre-wrap break-words leading-relaxed"
309
+ style={{ color: 'var(--c-text)', fontFamily: MONO }}
310
+ >{content}</pre>
311
+ )
312
+ }
313
+
314
+ export default function Artifacts() {
315
+ const [data, setData] = useState(null)
316
+ const [search, setSearch] = useState('')
317
+ const [expandedProjects, setExpandedProjects] = useState(new Set())
318
+ const [expandedEditors, setExpandedEditors] = useState(new Set())
319
+ const [selectedFile, setSelectedFile] = useState(null)
320
+ const [fileContent, setFileContent] = useState(null)
321
+ const [loadingContent, setLoadingContent] = useState(false)
322
+ const [viewRaw, setViewRaw] = useState(false)
323
+
324
+ useEffect(() => {
325
+ fetchArtifacts().then(d => {
326
+ setData(d)
327
+ // Auto-expand first project
328
+ if (d && d.length > 0) {
329
+ setExpandedProjects(new Set([d[0].folder]))
330
+ if (d[0].editors.length > 0) {
331
+ setExpandedEditors(new Set([d[0].folder + '::' + d[0].editors[0].editor]))
332
+ }
333
+ }
334
+ })
335
+ }, [])
336
+
337
+ const handleFileClick = async (file) => {
338
+ setSelectedFile(file)
339
+ setViewRaw(false)
340
+ setLoadingContent(true)
341
+ try {
342
+ const content = await fetchArtifactContent(file.path)
343
+ setFileContent(content)
344
+ } catch {
345
+ setFileContent({ error: 'Failed to load file content' })
346
+ }
347
+ setLoadingContent(false)
348
+ }
349
+
350
+ const toggleProject = (folder) => {
351
+ setExpandedProjects(prev => {
352
+ const next = new Set(prev)
353
+ if (next.has(folder)) next.delete(folder)
354
+ else next.add(folder)
355
+ return next
356
+ })
357
+ }
358
+
359
+ const toggleEditor = (key) => {
360
+ setExpandedEditors(prev => {
361
+ const next = new Set(prev)
362
+ if (next.has(key)) next.delete(key)
363
+ else next.add(key)
364
+ return next
365
+ })
366
+ }
367
+
368
+ if (!data) return <AnimatedLoader label="Scanning project artifacts..." />
369
+
370
+ const filtered = data.filter(p => {
371
+ if (!search) return true
372
+ const q = search.toLowerCase()
373
+ if (p.name.toLowerCase().includes(q) || p.folder.toLowerCase().includes(q)) return true
374
+ return p.editors.some(e =>
375
+ e.label.toLowerCase().includes(q) ||
376
+ e.files.some(f => f.name.toLowerCase().includes(q))
377
+ )
378
+ })
379
+
380
+ const totalArtifacts = data.reduce((s, p) => s + p.totalArtifacts, 0)
381
+ const totalProjects = data.length
382
+ const allEditors = new Set()
383
+ for (const p of data) for (const e of p.editors) allEditors.add(e.editor)
384
+
385
+ return (
386
+ <div className="h-full">
387
+ {/* Header */}
388
+ <div className="px-4 py-3" style={{ borderBottom: '1px solid var(--c-border)' }}>
389
+ <PageHeader icon={Package} title="Artifacts">
390
+ <span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>{totalArtifacts} artifacts · {totalProjects} projects · {allEditors.size} editors</span>
391
+ <div className="ml-auto relative">
392
+ <Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
393
+ <input
394
+ type="text"
395
+ placeholder="Search artifacts..."
396
+ value={search}
397
+ onChange={e => setSearch(e.target.value)}
398
+ className="pl-7 pr-3 py-1 text-[12px] outline-none w-[200px]"
399
+ style={{ background: 'var(--c-bg3)', border: '1px solid var(--c-border)', color: 'var(--c-text)' }}
400
+ />
401
+ </div>
402
+ </PageHeader>
403
+ </div>
404
+
405
+ {filtered.length === 0 ? (
406
+ <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text3)' }}>
407
+ {search ? 'No artifacts match your search' : 'No AI artifacts found in any project folders'}
408
+ </div>
409
+ ) : null}
410
+
411
+ <div className="flex" style={{ height: 'calc(100vh - 130px)' }}>
412
+ {/* Sidebar: project > editor tree */}
413
+ <div className="w-[340px] shrink-0 flex flex-col" style={{ background: 'var(--c-card)', borderRight: '1px solid var(--c-border)' }}>
414
+ <div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--c-text3)', borderBottom: '1px solid var(--c-border)' }}>
415
+ Project Tree
416
+ </div>
417
+ <div className="overflow-y-auto flex-1">
418
+ {filtered.map(project => {
419
+ const isExpanded = expandedProjects.has(project.folder)
420
+ return (
421
+ <div key={project.folder}>
422
+ {/* Project row */}
423
+ <button
424
+ onClick={() => toggleProject(project.folder)}
425
+ className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--c-bg3)] transition"
426
+ style={{ borderBottom: '1px solid var(--c-border)' }}
427
+ >
428
+ {isExpanded ? <ChevronDown size={12} style={{ color: 'var(--c-text3)' }} /> : <ChevronRight size={12} style={{ color: 'var(--c-text3)' }} />}
429
+ <FolderOpen size={13} style={{ color: '#6366f1' }} />
430
+ <div className="min-w-0 flex-1">
431
+ <div className="text-[12px] font-medium truncate" style={{ color: 'var(--c-white)' }}>{project.name}</div>
432
+ <div className="text-[10px] truncate" style={{ color: 'var(--c-text3)', fontFamily: MONO }}>{project.folder}</div>
433
+ </div>
434
+ <span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: 'rgba(99,102,241,0.12)', color: '#818cf8' }}>
435
+ {project.totalArtifacts}
436
+ </span>
437
+ </button>
438
+
439
+ {/* Editor groups */}
440
+ {isExpanded && project.editors.map(editorGroup => {
441
+ const editorKey = project.folder + '::' + editorGroup.editor
442
+ const isEditorExpanded = expandedEditors.has(editorKey)
443
+ return (
444
+ <div key={editorKey}>
445
+ <button
446
+ onClick={() => toggleEditor(editorKey)}
447
+ className="w-full flex items-center gap-2 pl-7 pr-3 py-1.5 text-left hover:bg-[var(--c-bg3)] transition"
448
+ >
449
+ {isEditorExpanded ? <ChevronDown size={10} style={{ color: 'var(--c-text3)' }} /> : <ChevronRight size={10} style={{ color: 'var(--c-text3)' }} />}
450
+ {editorGroup.editor !== '_general' ? (
451
+ <EditorIcon source={editorGroup.editor} size={12} />
452
+ ) : (
453
+ <FileText size={12} style={{ color: 'var(--c-text2)' }} />
454
+ )}
455
+ <span className="text-[11px] font-medium" style={{ color: editorGroup.editor !== '_general' ? editorColor(editorGroup.editor) : 'var(--c-text2)' }}>
456
+ {editorGroup.editor !== '_general' ? editorLabel(editorGroup.editor) : editorGroup.label}
457
+ </span>
458
+ <span className="text-[10px] ml-auto" style={{ color: 'var(--c-text3)' }}>
459
+ {editorGroup.files.length}
460
+ </span>
461
+ </button>
462
+
463
+ {/* File list */}
464
+ {isEditorExpanded && editorGroup.files.map(file => (
465
+ <button
466
+ key={file.path}
467
+ onClick={() => handleFileClick(file)}
468
+ className="w-full flex items-center gap-2 pl-12 pr-3 py-1.5 text-left hover:bg-[var(--c-bg3)] transition"
469
+ style={{
470
+ background: selectedFile?.path === file.path ? 'var(--c-bg3)' : 'transparent',
471
+ borderLeft: selectedFile?.path === file.path ? '2px solid #6366f1' : '2px solid transparent',
472
+ }}
473
+ >
474
+ <FileText size={11} style={{ color: 'var(--c-text3)' }} />
475
+ <div className="min-w-0 flex-1">
476
+ <div className="text-[11px] truncate" style={{ color: 'var(--c-text)', fontFamily: MONO }}>{file.relativePath}</div>
477
+ <div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>
478
+ {formatSize(file.size)} · {file.lines} lines
479
+ </div>
480
+ </div>
481
+ </button>
482
+ ))}
483
+ </div>
484
+ )
485
+ })}
486
+ </div>
487
+ )
488
+ })}
489
+ </div>
490
+ </div>
491
+
492
+ {/* Content panel — scrolls internally */}
493
+ <div className="flex-1 flex flex-col min-w-0 overflow-hidden" style={{ background: 'var(--c-card)' }}>
494
+ {!selectedFile ? (
495
+ <div className="flex items-center justify-center flex-1 text-[13px]" style={{ color: 'var(--c-text3)' }}>
496
+ <div className="text-center">
497
+ <Package size={32} className="mx-auto mb-3 opacity-30" />
498
+ <div>Select an artifact to view its contents</div>
499
+ <div className="text-[11px] mt-1 opacity-60">Click any file in the sidebar tree</div>
500
+ </div>
501
+ </div>
502
+ ) : (
503
+ <div className="flex flex-col h-full">
504
+ {/* File header */}
505
+ <div className="flex items-center gap-3 px-4 py-2.5" style={{ borderBottom: '1px solid var(--c-border)' }}>
506
+ <FileText size={14} style={{ color: '#6366f1' }} />
507
+ <div className="flex-1 min-w-0">
508
+ <div className="text-[12px] font-medium" style={{ color: 'var(--c-white)', fontFamily: MONO }}>{selectedFile.relativePath}</div>
509
+ <div className="flex items-center gap-3 text-[10px]" style={{ color: 'var(--c-text3)' }}>
510
+ <span>{formatSize(selectedFile.size)}</span>
511
+ <span>{selectedFile.lines} lines</span>
512
+ <span className="flex items-center gap-1">
513
+ <Clock size={9} />
514
+ {formatDate(selectedFile.modifiedAt)}
515
+ </span>
516
+ </div>
517
+ </div>
518
+ {fileContent?.content && (() => {
519
+ const tokens = estimateTokens(fileContent.content)
520
+ return (
521
+ <div className="flex items-center gap-2">
522
+ <span className="flex items-center gap-1 text-[10px] px-2 py-0.5 rounded" style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.15)', color: '#818cf8' }}>
523
+ <Type size={9} />
524
+ {formatTokens(tokens)} tokens
525
+ </span>
526
+ <span className="flex items-center gap-1 text-[10px] px-2 py-0.5 rounded" style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.15)', color: '#22c55e' }}>
527
+ <DollarSign size={9} />
528
+ {estimateCost(tokens)}
529
+ </span>
530
+ </div>
531
+ )
532
+ })()}
533
+ <span className="flex items-center gap-1.5 text-[10px] px-2 py-0.5 rounded" style={{ background: `${editorColor(selectedFile.editor)}15`, color: editorColor(selectedFile.editor) }}>
534
+ {selectedFile.editor !== '_general' && <EditorIcon source={selectedFile.editor} size={11} />}
535
+ {selectedFile.editorLabel}
536
+ </span>
537
+ <button
538
+ onClick={() => setViewRaw(v => !v)}
539
+ className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium transition"
540
+ style={{
541
+ background: viewRaw ? 'rgba(99,102,241,0.15)' : 'var(--c-bg3)',
542
+ color: viewRaw ? '#818cf8' : 'var(--c-text2)',
543
+ border: '1px solid ' + (viewRaw ? 'rgba(99,102,241,0.3)' : 'var(--c-border)'),
544
+ }}
545
+ title={viewRaw ? 'Switch to rendered view' : 'Switch to raw view'}
546
+ >
547
+ {viewRaw ? <Eye size={12} /> : <Code size={12} />}
548
+ {viewRaw ? 'Rendered' : 'Raw'}
549
+ </button>
550
+ <button
551
+ onClick={() => { setSelectedFile(null); setFileContent(null) }}
552
+ className="p-1 rounded hover:bg-[var(--c-bg3)] transition"
553
+ style={{ color: 'var(--c-text3)' }}
554
+ >
555
+ <X size={14} />
556
+ </button>
557
+ </div>
558
+
559
+ {/* File content — internal scroll */}
560
+ <div className="flex-1 overflow-y-auto p-4">
561
+ {loadingContent ? (
562
+ <div className="text-[12px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>Loading...</div>
563
+ ) : fileContent?.error ? (
564
+ <div className="text-[12px] py-8 text-center" style={{ color: '#ef4444' }}>{fileContent.error}</div>
565
+ ) : fileContent?.content ? (
566
+ <ArtifactContent content={fileContent.content} fileName={selectedFile.name} viewRaw={viewRaw} />
567
+ ) : null}
568
+ </div>
569
+ </div>
570
+ )}
571
+ </div>
572
+ </div>
573
+ </div>
574
+ )
575
+ }