agentlytics 0.0.7 → 0.0.10

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,313 @@
1
+ import { useState, useEffect, useRef } from 'react'
2
+ import { X, User, Bot, Wrench, Settings, Play, CheckCircle, ChevronRight, ChevronDown, Download, Send, Search } from 'lucide-react'
3
+ import ReactMarkdown from 'react-markdown'
4
+ import remarkGfm from 'remark-gfm'
5
+ import { fetchChat, BASE } from '../lib/api'
6
+ import { editorColor, editorLabel, formatDateTime, formatNumber } from '../lib/constants'
7
+
8
+ const ROLE_CONFIG = {
9
+ user: { icon: User, label: 'User', borderColor: 'rgba(34,197,94,0.2)', bg: 'rgba(34,197,94,0.05)' },
10
+ assistant: { icon: Bot, label: 'Assistant', borderColor: 'rgba(99,102,241,0.2)', bg: 'rgba(99,102,241,0.05)' },
11
+ system: { icon: Settings, label: 'System', borderColor: 'rgba(107,114,128,0.2)', bg: 'rgba(107,114,128,0.05)' },
12
+ tool: { icon: Wrench, label: 'Tool', borderColor: 'rgba(234,179,8,0.2)', bg: 'rgba(234,179,8,0.05)' },
13
+ }
14
+
15
+ function parseContent(content) {
16
+ const segments = []
17
+ const regex = /\[tool-call: ([^\]]+)\]|\[tool-result: ([^\]]+)\]\s*(.*?)(?=\n\[tool-|$)/gs
18
+ let lastIdx = 0
19
+ let match
20
+ while ((match = regex.exec(content)) !== null) {
21
+ if (match.index > lastIdx) {
22
+ const text = content.slice(lastIdx, match.index).trim()
23
+ if (text) segments.push({ type: 'text', value: text })
24
+ }
25
+ if (match[1]) {
26
+ segments.push({ type: 'tool-call', name: match[1].replace(/\(.*\)$/, '').trim(), args: match[1] })
27
+ } else if (match[2]) {
28
+ segments.push({ type: 'tool-result', name: match[2].trim(), preview: (match[3] || '').trim() })
29
+ }
30
+ lastIdx = match.index + match[0].length
31
+ }
32
+ if (lastIdx < content.length) {
33
+ const text = content.slice(lastIdx).trim()
34
+ if (text) segments.push({ type: 'text', value: text })
35
+ }
36
+ return segments.length > 0 ? segments : [{ type: 'text', value: content }]
37
+ }
38
+
39
+ function summarizeToolArgs(name, args) {
40
+ if (!args || typeof args !== 'object') return ''
41
+ if (args.file_path || args.TargetFile) return args.file_path || args.TargetFile
42
+ if (args.CommandLine || args.command) return args.CommandLine || args.command
43
+ if (args.Query || args.query) return `${args.Query || args.query}${args.SearchPath ? ` in ${args.SearchPath}` : ''}`
44
+ if (args.Url || args.url) return args.Url || args.url
45
+ const vals = Object.values(args).filter(v => typeof v === 'string' && v.length > 0 && v.length < 120)
46
+ return vals.length > 0 ? vals[0] : ''
47
+ }
48
+
49
+ function ToolArgsDiff({ args }) {
50
+ const old = args.old_string || args.old_text || args.oldText || args.search || null
51
+ const nw = args.new_string || args.new_text || args.newText || args.replace || null
52
+ if (old == null && nw == null) return null
53
+ const maxLines = 8
54
+ const oldLines = (old || '').split('\n').slice(0, maxLines)
55
+ const newLines = (nw || '').split('\n').slice(0, maxLines)
56
+ return (
57
+ <div className="mt-1 text-[9px] font-mono overflow-x-auto" style={{ border: '1px solid var(--c-border)' }}>
58
+ {(args.file_path || args.TargetFile) && (
59
+ <div className="px-2 py-0.5" style={{ background: 'var(--c-code-bg)', color: 'var(--c-text)' }}>{args.file_path || args.TargetFile}</div>
60
+ )}
61
+ {old && oldLines.map((line, i) => (
62
+ <div key={'o' + i} className="px-2" style={{ background: 'rgba(248,113,113,0.1)', color: '#f87171' }}>
63
+ <span style={{ color: 'var(--c-text3)', userSelect: 'none' }}>- </span>{line}
64
+ </div>
65
+ ))}
66
+ {old && oldLines.length < (old || '').split('\n').length && (
67
+ <div className="px-2" style={{ color: 'var(--c-text3)' }}> ... {(old || '').split('\n').length - maxLines} more lines</div>
68
+ )}
69
+ {nw && newLines.map((line, i) => (
70
+ <div key={'n' + i} className="px-2" style={{ background: 'rgba(52,211,153,0.1)', color: '#34d399' }}>
71
+ <span style={{ color: 'var(--c-text3)', userSelect: 'none' }}>+ </span>{line}
72
+ </div>
73
+ ))}
74
+ {nw && newLines.length < (nw || '').split('\n').length && (
75
+ <div className="px-2" style={{ color: 'var(--c-text3)' }}> ... {(nw || '').split('\n').length - maxLines} more lines</div>
76
+ )}
77
+ </div>
78
+ )
79
+ }
80
+
81
+ function ToolArgsDetail({ args }) {
82
+ if (!args || Object.keys(args).length === 0) return null
83
+ const hasDiff = args.old_string || args.new_string || args.old_text || args.new_text || args.search || args.replace
84
+ if (hasDiff) return <ToolArgsDiff args={args} />
85
+ const file = args.file_path || args.TargetFile || args.filePath || args.path || null
86
+ const cmd = args.CommandLine || args.command || null
87
+ const query = args.Query || args.query || args.search_term || null
88
+ const url = args.Url || args.url || null
89
+ return (
90
+ <div className="mt-1 text-[9px] font-mono overflow-x-auto" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
91
+ {file && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>file: {file}</div>}
92
+ {cmd && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>cmd: {cmd}</div>}
93
+ {query && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>query: {query}</div>}
94
+ {url && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>url: {url}</div>}
95
+ {!file && !cmd && !query && !url && (
96
+ <pre className="px-2 py-1 whitespace-pre-wrap break-all" style={{ color: 'var(--c-text2)' }}>{JSON.stringify(args, null, 2)}</pre>
97
+ )}
98
+ </div>
99
+ )
100
+ }
101
+
102
+ function ToolCallBlock({ name, args, detail }) {
103
+ const [open, setOpen] = useState(false)
104
+ const hasDetail = detail && Object.keys(detail).length > 0
105
+ return (
106
+ <div className="my-1 px-2 py-1 text-[10px]" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
107
+ <div className="flex items-center gap-1.5 cursor-pointer" onClick={() => hasDetail && setOpen(!open)}>
108
+ {hasDetail
109
+ ? (open ? <ChevronDown size={9} style={{ color: '#a78bfa' }} /> : <ChevronRight size={9} style={{ color: '#a78bfa' }} />)
110
+ : <Play size={9} style={{ color: '#a78bfa' }} />
111
+ }
112
+ <span className="font-bold" style={{ color: 'var(--c-white)' }}>{name}</span>
113
+ {args !== name && !hasDetail && <span className="truncate" style={{ color: 'var(--c-text2)' }}>{args}</span>}
114
+ {hasDetail && <span className="truncate" style={{ color: 'var(--c-text2)' }}>{summarizeToolArgs(name, detail)}</span>}
115
+ </div>
116
+ {open && hasDetail && <ToolArgsDetail args={detail} />}
117
+ </div>
118
+ )
119
+ }
120
+
121
+ function ToolResultBlock({ name, preview }) {
122
+ const [open, setOpen] = useState(false)
123
+ const isNoisy = preview.length > 120 || preview.startsWith('{') || preview.includes('contentId')
124
+ const short = isNoisy ? `${name} completed` : preview.substring(0, 120)
125
+ return (
126
+ <div className="my-1 px-2 py-1 text-[10px]" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
127
+ <div className="flex items-center gap-1.5 cursor-pointer" onClick={() => preview && setOpen(!open)}>
128
+ <CheckCircle size={9} style={{ color: '#34d399' }} />
129
+ <span className="truncate" style={{ color: 'var(--c-text)' }}>{short}</span>
130
+ {isNoisy && preview && <span style={{ color: 'var(--c-text3)' }}>{open ? '[-]' : '[+]'}</span>}
131
+ </div>
132
+ {open && <pre className="mt-1 text-[9px] overflow-x-auto whitespace-pre-wrap break-all" style={{ color: 'var(--c-text2)' }}>{preview}</pre>}
133
+ </div>
134
+ )
135
+ }
136
+
137
+ function MessageContent({ content, toolCallDetails }) {
138
+ const segments = parseContent(content)
139
+ let toolIdx = 0
140
+ return segments.map((seg, i) => {
141
+ if (seg.type === 'tool-call') {
142
+ const detail = toolCallDetails ? toolCallDetails.find(tc => tc.name === seg.name && toolCallDetails.indexOf(tc) >= toolIdx) : null
143
+ if (detail) toolIdx = toolCallDetails.indexOf(detail) + 1
144
+ return <ToolCallBlock key={i} name={seg.name} args={seg.args} detail={detail?.args} />
145
+ }
146
+ if (seg.type === 'tool-result') return <ToolResultBlock key={i} name={seg.name} preview={seg.preview} />
147
+ return <div key={i} className="md-body text-[12px] leading-relaxed"><ReactMarkdown remarkPlugins={[remarkGfm]}>{seg.value}</ReactMarkdown></div>
148
+ })
149
+ }
150
+
151
+ export default function ChatSidebar({ chatId, onClose }) {
152
+ const [chat, setChat] = useState(null)
153
+ const [loading, setLoading] = useState(true)
154
+ const [msgFilter, setMsgFilter] = useState('')
155
+ const scrollRef = useRef(null)
156
+
157
+ useEffect(() => {
158
+ if (!chatId) return
159
+ setLoading(true)
160
+ setChat(null)
161
+ fetchChat(chatId).then(data => {
162
+ setChat(data)
163
+ setLoading(false)
164
+ })
165
+ }, [chatId])
166
+
167
+ // Scroll to bottom when chat loads
168
+ useEffect(() => {
169
+ if (!loading && chat && scrollRef.current) {
170
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight
171
+ }
172
+ }, [loading, chat])
173
+
174
+ // Reset filter when chat changes
175
+ useEffect(() => {
176
+ setMsgFilter('')
177
+ }, [chatId])
178
+
179
+ if (!chatId) return null
180
+
181
+ return (
182
+ <>
183
+ {/* Backdrop */}
184
+ <div
185
+ className="fixed inset-0 z-40 transition-opacity"
186
+ style={{ background: 'rgba(0,0,0,0.3)' }}
187
+ onClick={onClose}
188
+ />
189
+
190
+ {/* Sidebar */}
191
+ <div
192
+ className="fixed top-0 right-0 bottom-0 z-50 flex flex-col shadow-2xl sidebar-slide-in"
193
+ style={{
194
+ width: 'min(580px, 90vw)',
195
+ background: 'var(--c-bg)',
196
+ borderLeft: '1px solid var(--c-border)',
197
+ }}
198
+ >
199
+ {/* Header */}
200
+ <div className="flex items-center gap-3 px-4 py-3 shrink-0" style={{ borderBottom: '1px solid var(--c-border)' }}>
201
+ <button onClick={onClose} className="p-1 rounded transition hover:bg-[var(--c-bg3)]" style={{ color: 'var(--c-text2)' }}>
202
+ <X size={14} />
203
+ </button>
204
+ {chat && (
205
+ <div className="flex-1 min-w-0">
206
+ <div className="text-sm font-medium truncate" style={{ color: 'var(--c-white)' }}>
207
+ {chat.name || '(untitled)'}
208
+ </div>
209
+ <div className="flex items-center gap-2 text-[10px]" style={{ color: 'var(--c-text2)' }}>
210
+ <span className="inline-flex items-center gap-1">
211
+ <span className="w-1.5 h-1.5 rounded-full" style={{ background: editorColor(chat.source) }} />
212
+ {editorLabel(chat.source)}
213
+ </span>
214
+ {chat.mode && <span>· {chat.mode}</span>}
215
+ <span>{formatDateTime(chat.createdAt)}</span>
216
+ </div>
217
+ </div>
218
+ )}
219
+ {chat && (
220
+ <a
221
+ href={`${BASE}/api/chats/${chat.id}/markdown`}
222
+ download
223
+ className="flex items-center gap-1 px-2 py-1 text-[10px] transition shrink-0"
224
+ style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
225
+ >
226
+ <Download size={11} /> .md
227
+ </a>
228
+ )}
229
+ </div>
230
+
231
+ {/* Stats row */}
232
+ {chat?.stats && (
233
+ <div className="flex items-center gap-3 px-4 py-2 text-[10px] shrink-0" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text2)' }}>
234
+ <span>{chat.stats.totalMessages} msgs</span>
235
+ <span>{chat.stats.toolCalls.length} tools</span>
236
+ {chat.stats.totalInputTokens > 0 && <span>{formatNumber(chat.stats.totalInputTokens)} in</span>}
237
+ {chat.stats.totalOutputTokens > 0 && <span>{formatNumber(chat.stats.totalOutputTokens)} out</span>}
238
+ {chat.stats.models.length > 0 && (
239
+ <span className="ml-auto font-mono truncate" style={{ color: 'var(--c-accent)', opacity: 0.7 }}>
240
+ {[...new Set(chat.stats.models)].join(', ')}
241
+ </span>
242
+ )}
243
+ </div>
244
+ )}
245
+
246
+ {/* Search bar */}
247
+ {chat && chat.messages.length > 0 && (
248
+ <div className="shrink-0 px-4 py-2" style={{ borderBottom: '1px solid var(--c-border)' }}>
249
+ <div className="relative">
250
+ <Search size={11} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
251
+ <input
252
+ type="text"
253
+ placeholder="Filter messages..."
254
+ value={msgFilter}
255
+ onChange={e => setMsgFilter(e.target.value)}
256
+ className="w-full pl-7 pr-3 py-1 text-[11px] outline-none"
257
+ style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
258
+ />
259
+ </div>
260
+ </div>
261
+ )}
262
+
263
+ {/* Messages */}
264
+ <div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-thin px-4 py-3 space-y-2">
265
+ {loading && (
266
+ <div className="text-[11px] py-12 text-center" style={{ color: 'var(--c-text3)' }}>Loading conversation...</div>
267
+ )}
268
+ {!loading && chat && chat.messages.length === 0 && (
269
+ <div className="text-[11px] py-12 text-center" style={{ color: 'var(--c-text3)' }}>
270
+ {chat.encrypted ? '🔒 This conversation is encrypted.' : 'No messages found.'}
271
+ </div>
272
+ )}
273
+ {!loading && chat && chat.messages
274
+ .filter(msg => !msgFilter || msg.content.toLowerCase().includes(msgFilter.toLowerCase()))
275
+ .map((msg, i) => {
276
+ const cfg = ROLE_CONFIG[msg.role] || ROLE_CONFIG.system
277
+ const Icon = cfg.icon
278
+ return (
279
+ <div key={i} className="rounded-r px-3 py-2" style={{ borderLeft: `2px solid ${cfg.borderColor}`, background: cfg.bg }}>
280
+ <div className="flex items-center gap-1.5 text-[10px] mb-1" style={{ color: 'var(--c-text2)' }}>
281
+ <Icon size={11} />
282
+ <span className="font-medium">{cfg.label}</span>
283
+ {msg.model && <span className="font-mono" style={{ color: 'var(--c-accent)', opacity: 0.6 }}>· {msg.model}</span>}
284
+ </div>
285
+ <div className="text-[12px]" style={{ color: 'var(--c-text)' }}>
286
+ <MessageContent content={msg.content} toolCallDetails={chat.toolCallDetails} />
287
+ </div>
288
+ </div>
289
+ )
290
+ })}
291
+ </div>
292
+
293
+ {/* Fake disabled chat input */}
294
+ <div className="shrink-0 px-4 py-3" style={{ borderTop: '1px solid var(--c-border)' }}>
295
+ <div
296
+ className="flex items-center gap-2 px-3 py-2.5 rounded-lg"
297
+ style={{
298
+ background: 'var(--c-bg3)',
299
+ border: '1px solid var(--c-border)',
300
+ opacity: 0.5,
301
+ cursor: 'not-allowed',
302
+ }}
303
+ >
304
+ <span className="flex-1 text-[12px]" style={{ color: 'var(--c-text3)' }}>
305
+ Message is read-only...
306
+ </span>
307
+ <Send size={13} style={{ color: 'var(--c-text3)' }} />
308
+ </div>
309
+ </div>
310
+ </div>
311
+ </>
312
+ )
313
+ }
@@ -0,0 +1,104 @@
1
+ import { Calendar, X } from 'lucide-react'
2
+ import { useTheme } from '../lib/theme'
3
+
4
+ const PRESETS = [
5
+ { label: '7d', days: 7 },
6
+ { label: '30d', days: 30 },
7
+ { label: '90d', days: 90 },
8
+ { label: '1y', days: 365 },
9
+ ]
10
+
11
+ // value: { from: 'YYYY-MM-DD', to: 'YYYY-MM-DD' } | null
12
+ // onChange: called with same shape, or null to clear
13
+ export default function DateRangePicker({ value, onChange }) {
14
+ const { dark } = useTheme()
15
+ const today = new Date().toISOString().split('T')[0]
16
+ const active = !!value
17
+
18
+ function applyPreset(days) {
19
+ const to = new Date()
20
+ const from = new Date(to.getTime() - days * 86400000)
21
+ onChange({
22
+ from: from.toISOString().split('T')[0],
23
+ to: to.toISOString().split('T')[0],
24
+ })
25
+ }
26
+
27
+ function setFrom(from) {
28
+ onChange({ from, to: value?.to || today })
29
+ }
30
+
31
+ function setTo(to) {
32
+ onChange({ from: value?.from || today, to })
33
+ }
34
+
35
+ return (
36
+ <div className="flex items-center gap-1.5 flex-wrap">
37
+ <Calendar size={12} style={{ color: active ? 'var(--c-accent)' : 'var(--c-text3)', flexShrink: 0 }} />
38
+
39
+ {/* Preset buttons */}
40
+ {PRESETS.map(p => {
41
+ const isActive = active && (() => {
42
+ const diff = Math.round((Date.now() - new Date(value.from).getTime()) / 86400000)
43
+ return diff >= p.days - 1 && diff <= p.days + 1
44
+ })()
45
+ return (
46
+ <button
47
+ key={p.label}
48
+ onClick={() => applyPreset(p.days)}
49
+ className="px-2 py-0.5 text-[10px] transition"
50
+ style={{
51
+ border: isActive ? '1px solid var(--c-accent)' : '1px solid var(--c-border)',
52
+ color: isActive ? 'var(--c-accent)' : 'var(--c-text2)',
53
+ background: isActive ? 'rgba(99,102,241,0.1)' : 'transparent',
54
+ }}
55
+ >
56
+ {p.label}
57
+ </button>
58
+ )
59
+ })}
60
+
61
+ {/* Custom date inputs */}
62
+ <input
63
+ type="date"
64
+ value={value?.from || ''}
65
+ max={value?.to || today}
66
+ onChange={e => setFrom(e.target.value)}
67
+ className="px-1.5 py-0.5 text-[10px] outline-none cursor-pointer"
68
+ style={{
69
+ background: 'var(--c-bg3)',
70
+ color: 'var(--c-text)',
71
+ border: '1px solid var(--c-border)',
72
+ colorScheme: dark ? 'dark' : 'light',
73
+ }}
74
+ />
75
+ <span className="text-[10px]" style={{ color: 'var(--c-text3)' }}>—</span>
76
+ <input
77
+ type="date"
78
+ value={value?.to || ''}
79
+ min={value?.from || ''}
80
+ max={today}
81
+ onChange={e => setTo(e.target.value)}
82
+ className="px-1.5 py-0.5 text-[10px] outline-none cursor-pointer"
83
+ style={{
84
+ background: 'var(--c-bg3)',
85
+ color: 'var(--c-text)',
86
+ border: '1px solid var(--c-border)',
87
+ colorScheme: dark ? 'dark' : 'light',
88
+ }}
89
+ />
90
+
91
+ {/* Clear button */}
92
+ {active && (
93
+ <button
94
+ onClick={() => onChange(null)}
95
+ className="flex items-center gap-0.5 px-2 py-0.5 text-[10px] transition"
96
+ style={{ border: '1px solid var(--c-accent)', color: 'var(--c-accent)' }}
97
+ >
98
+ <X size={9} /> clear
99
+ </button>
100
+ )}
101
+ </div>
102
+ )
103
+ }
104
+
package/ui/src/index.css CHANGED
@@ -60,6 +60,12 @@ body {
60
60
  .fade-in { animation: fadeIn 0.2s ease-out; }
61
61
  @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
62
62
 
63
+ .sidebar-slide-in { animation: slideInRight 0.2s ease-out; }
64
+ @keyframes slideInRight { from { transform: translateX(100%); } to { transform: translateX(0); } }
65
+
66
+ .pulse-dot { animation: pulseDot 1.5s ease-in-out infinite; }
67
+ @keyframes pulseDot { 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(34,197,94,0.5); } 50% { opacity: 0.6; box-shadow: 0 0 0 4px rgba(34,197,94,0); } }
68
+
63
69
  /* Markdown */
64
70
  .md-body h1,.md-body h2,.md-body h3 { font-weight:600; margin:8px 0 4px; color:var(--c-white); }
65
71
  .md-body h1 { font-size:1.15em; } .md-body h2 { font-size:1.05em; } .md-body h3 { font-size:1em; }
package/ui/src/lib/api.js CHANGED
@@ -1,8 +1,15 @@
1
- const BASE = '';
1
+ export const BASE = '';
2
+
3
+ // Append optional dateFrom/dateTo (ms timestamps) to URLSearchParams
4
+ function appendDateParams(q, params) {
5
+ if (params.dateFrom) q.set('dateFrom', params.dateFrom);
6
+ if (params.dateTo) q.set('dateTo', params.dateTo);
7
+ }
2
8
 
3
9
  export async function fetchOverview(params = {}) {
4
10
  const q = new URLSearchParams();
5
11
  if (params.editor) q.set('editor', params.editor);
12
+ appendDateParams(q, params);
6
13
  const qs = q.toString();
7
14
  const res = await fetch(`${BASE}/api/overview${qs ? '?' + qs : ''}`);
8
15
  return res.json();
@@ -15,6 +22,7 @@ export async function fetchChats(params = {}) {
15
22
  if (params.limit) q.set('limit', params.limit);
16
23
  if (params.offset) q.set('offset', params.offset);
17
24
  if (params.named === false) q.set('named', 'false');
25
+ appendDateParams(q, params);
18
26
  const res = await fetch(`${BASE}/api/chats?${q}`);
19
27
  return res.json();
20
28
  }
@@ -24,14 +32,18 @@ export async function fetchChat(id) {
24
32
  return res.json();
25
33
  }
26
34
 
27
- export async function fetchProjects() {
28
- const res = await fetch(`${BASE}/api/projects`);
35
+ export async function fetchProjects(params = {}) {
36
+ const q = new URLSearchParams();
37
+ appendDateParams(q, params);
38
+ const qs = q.toString();
39
+ const res = await fetch(`${BASE}/api/projects${qs ? '?' + qs : ''}`);
29
40
  return res.json();
30
41
  }
31
42
 
32
43
  export async function fetchDailyActivity(params = {}) {
33
44
  const q = new URLSearchParams();
34
45
  if (params.editor) q.set('editor', params.editor);
46
+ appendDateParams(q, params);
35
47
  const qs = q.toString();
36
48
  const res = await fetch(`${BASE}/api/daily-activity${qs ? '?' + qs : ''}`);
37
49
  return res.json();
@@ -42,6 +54,7 @@ export async function fetchDeepAnalytics(params = {}) {
42
54
  if (params.editor) q.set('editor', params.editor);
43
55
  if (params.folder) q.set('folder', params.folder);
44
56
  if (params.limit) q.set('limit', params.limit);
57
+ appendDateParams(q, params);
45
58
  const res = await fetch(`${BASE}/api/deep-analytics?${q}`);
46
59
  return res.json();
47
60
  }
@@ -64,6 +77,7 @@ export function refetchAgents(onProgress) {
64
77
  export async function fetchDashboardStats(params = {}) {
65
78
  const q = new URLSearchParams();
66
79
  if (params.editor) q.set('editor', params.editor);
80
+ appendDateParams(q, params);
67
81
  const qs = q.toString();
68
82
  const res = await fetch(`${BASE}/api/dashboard-stats${qs ? '?' + qs : ''}`);
69
83
  return res.json();
@@ -9,9 +9,11 @@ export const EDITOR_COLORS = {
9
9
  'vscode-insiders': '#60a5fa',
10
10
  'zed': '#10b981',
11
11
  'opencode': '#ec4899',
12
+ 'codex': '#0f766e',
12
13
  'gemini-cli': '#4285f4',
13
14
  'copilot-cli': '#8957e5',
14
15
  'cursor-agent': '#f59e0b',
16
+ 'commandcode': '#e11d48',
15
17
  };
16
18
 
17
19
  export const EDITOR_LABELS = {
@@ -25,9 +27,11 @@ export const EDITOR_LABELS = {
25
27
  'vscode-insiders': 'VS Code Insiders',
26
28
  'zed': 'Zed',
27
29
  'opencode': 'OpenCode',
30
+ 'codex': 'Codex',
28
31
  'gemini-cli': 'Gemini CLI',
29
32
  'copilot-cli': 'Copilot CLI',
30
33
  'cursor-agent': 'Cursor Agent',
34
+ 'commandcode': 'Command Code',
31
35
  };
32
36
 
33
37
  export function editorColor(src) {
@@ -50,6 +54,18 @@ export function formatDate(ts) {
50
54
  return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
51
55
  }
52
56
 
57
+ /**
58
+ * Convert { from: 'YYYY-MM-DD', to: 'YYYY-MM-DD' } date range to API ms timestamps.
59
+ * Returns {} if range is null/incomplete.
60
+ */
61
+ export function dateRangeToApiParams(range) {
62
+ if (!range?.from || !range?.to) return {};
63
+ return {
64
+ dateFrom: new Date(range.from).getTime(),
65
+ dateTo: new Date(range.to + 'T23:59:59').getTime(),
66
+ };
67
+ }
68
+
53
69
  export function formatDateTime(ts) {
54
70
  if (!ts) return '';
55
71
  return new Date(ts).toLocaleString();
@@ -1,9 +1,9 @@
1
1
  import { useState, useEffect } from 'react'
2
2
  import { useParams, useNavigate } from 'react-router-dom'
3
- import { ArrowLeft, User, Bot, Wrench, Settings, Play, CheckCircle, ChevronRight, ChevronDown } from 'lucide-react'
3
+ import { ArrowLeft, User, Bot, Wrench, Settings, Play, CheckCircle, ChevronRight, ChevronDown, Download } from 'lucide-react'
4
4
  import ReactMarkdown from 'react-markdown'
5
5
  import remarkGfm from 'remark-gfm'
6
- import { fetchChat } from '../lib/api'
6
+ import { fetchChat, BASE } from '../lib/api'
7
7
  import { editorColor, editorLabel, formatDateTime, formatNumber } from '../lib/constants'
8
8
  import KpiCard from '../components/KpiCard'
9
9
 
@@ -184,7 +184,7 @@ export default function ChatDetail() {
184
184
  {/* Header */}
185
185
  <div className="card p-5 mb-4">
186
186
  <div className="flex items-start justify-between">
187
- <div>
187
+ <div className="flex-1 min-w-0">
188
188
  <h2 className="text-xl font-semibold mb-1" style={{ color: 'var(--c-white)' }}>{chat.name || '(untitled)'}</h2>
189
189
  <div className="flex items-center gap-3 text-xs" style={{ color: 'var(--c-text2)' }}>
190
190
  <span className="inline-flex items-center gap-1.5">
@@ -200,6 +200,16 @@ export default function ChatDetail() {
200
200
  <span className="ml-3 font-mono" style={{ color: 'var(--c-text3)' }}>{chat.id}</span>
201
201
  </div>
202
202
  </div>
203
+ <a
204
+ href={`${BASE}/api/chats/${chat.id}/markdown`}
205
+ download
206
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition shrink-0"
207
+ style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
208
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg2)'}
209
+ onMouseLeave={e => e.currentTarget.style.background = 'var(--c-bg3)'}
210
+ >
211
+ <Download size={13} /> Export .md
212
+ </a>
203
213
  </div>
204
214
  </div>
205
215
 
@@ -5,8 +5,9 @@ import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearSca
5
5
  import { Doughnut, Bar, Line } from 'react-chartjs-2'
6
6
  import KpiCard from '../components/KpiCard'
7
7
  import ActivityHeatmap from '../components/ActivityHeatmap'
8
+ import DateRangePicker from '../components/DateRangePicker'
9
+ import { editorColor, editorLabel, formatNumber, dateRangeToApiParams } from '../lib/constants'
8
10
  import { fetchDailyActivity, fetchOverview as fetchOverviewApi, fetchDashboardStats, fetchShareImage } from '../lib/api'
9
- import { editorColor, editorLabel, formatNumber } from '../lib/constants'
10
11
  import { useTheme } from '../lib/theme'
11
12
 
12
13
  ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement, Filler)
@@ -29,6 +30,7 @@ export default function Dashboard({ overview }) {
29
30
  const [filteredData, setFilteredData] = useState(null)
30
31
  const [stats, setStats] = useState(null)
31
32
  const [selectedEditor, setSelectedEditor] = useState(null)
33
+ const [dateRange, setDateRange] = useState(null)
32
34
  const { dark } = useTheme()
33
35
  const [sharing, setSharing] = useState(false)
34
36
  const txtColor = dark ? '#888' : '#555'
@@ -50,27 +52,23 @@ export default function Dashboard({ overview }) {
50
52
  const noLegend = { legend: { display: false }, tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } } }
51
53
 
52
54
  useEffect(() => {
53
- fetchDailyActivity().then(setDailyData)
54
- fetchDashboardStats().then(setStats)
55
- }, [])
56
-
57
- useEffect(() => {
55
+ const dateParams = dateRangeToApiParams(dateRange)
58
56
  if (!selectedEditor) {
59
57
  setFilteredData(null)
60
- fetchDailyActivity().then(setDailyData)
61
- fetchDashboardStats().then(setStats)
58
+ fetchDailyActivity(dateParams).then(setDailyData)
59
+ fetchDashboardStats(dateParams).then(setStats)
62
60
  return
63
61
  }
64
62
  Promise.all([
65
- fetchOverviewApi({ editor: selectedEditor }),
66
- fetchDailyActivity({ editor: selectedEditor }),
67
- fetchDashboardStats({ editor: selectedEditor }),
63
+ fetchOverviewApi({ editor: selectedEditor, ...dateParams }),
64
+ fetchDailyActivity({ editor: selectedEditor, ...dateParams }),
65
+ fetchDashboardStats({ editor: selectedEditor, ...dateParams }),
68
66
  ]).then(([ov, daily, st]) => {
69
67
  setFilteredData(ov)
70
68
  setDailyData(daily)
71
69
  setStats(st)
72
70
  })
73
- }, [selectedEditor])
71
+ }, [selectedEditor, dateRange])
74
72
 
75
73
  if (!overview) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading...</div>
76
74
 
@@ -209,8 +207,9 @@ export default function Dashboard({ overview }) {
209
207
 
210
208
  return (
211
209
  <div className="fade-in space-y-3">
212
- {/* Share button */}
213
- <div className="flex justify-end">
210
+ {/* Top bar */}
211
+ <div className="flex items-center justify-end gap-3">
212
+ <DateRangePicker value={dateRange} onChange={setDateRange} />
214
213
  <button
215
214
  onClick={handleShare}
216
215
  disabled={sharing}
@@ -261,6 +260,7 @@ export default function Dashboard({ overview }) {
261
260
  )}
262
261
  </div>
263
262
 
263
+
264
264
  {/* KPIs row 1: Core stats */}
265
265
  <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-2">
266
266
  <KpiCard label="total sessions" value={formatNumber(d.totalChats)} sub={sel ? editorLabel(sel.id) : `${allEditors.length} editors`} />