agentlytics 0.2.11 → 0.2.13

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,258 @@
1
+ import { useState, useRef, useCallback, useMemo } from 'react'
2
+ import { Activity, ChevronDown, ChevronUp, Clock } from 'lucide-react'
3
+ import { formatNumber } from '../lib/constants'
4
+
5
+ const CHART_H = 56
6
+ const POINT_STEP = 52
7
+ const PAD_X = 20
8
+ const PAD_Y = 6
9
+ const BUCKET_MINUTES = 5
10
+ const BUCKET_MSG_COUNT = 8
11
+
12
+ const AVG_TOKEN_SPEED = 80
13
+
14
+ function formatDuration(ms) {
15
+ if (ms < 60000) return `${Math.round(ms / 1000)}s`
16
+ const mins = Math.floor(ms / 60000)
17
+ if (mins < 60) return `${mins}m`
18
+ const hrs = Math.floor(mins / 60)
19
+ const rem = mins % 60
20
+ return rem > 0 ? `${hrs}h ${rem}m` : `${hrs}h`
21
+ }
22
+
23
+ function lerp(a, b, t) {
24
+ return a + (b - a) * Math.max(0, Math.min(1, t))
25
+ }
26
+
27
+ function tokenColor(ratio) {
28
+ const r = Math.round(lerp(52, 239, ratio))
29
+ const g = Math.round(lerp(211, 68, ratio))
30
+ const b = Math.round(lerp(153, 68, ratio))
31
+ return `rgb(${r},${g},${b})`
32
+ }
33
+
34
+ function buildTimeBuckets(messages, createdAt, lastUpdatedAt) {
35
+ const start = new Date(createdAt).getTime()
36
+ const end = new Date(lastUpdatedAt).getTime()
37
+ const duration = Math.max(end - start, 1)
38
+ const bucketMs = BUCKET_MINUTES * 60 * 1000
39
+ const bucketCount = Math.max(1, Math.ceil(duration / bucketMs))
40
+
41
+ const buckets = Array.from({ length: bucketCount }, (_, i) => ({
42
+ label: `${i * BUCKET_MINUTES}m`,
43
+ tokens: 0,
44
+ messageIndices: [],
45
+ }))
46
+
47
+ messages.forEach((msg, idx) => {
48
+ const totalTokens = (msg.inputTokens || 0) + (msg.outputTokens || 0)
49
+ const msgTime = start + (idx / Math.max(messages.length - 1, 1)) * duration
50
+ const bucketIdx = Math.min(Math.floor((msgTime - start) / bucketMs), bucketCount - 1)
51
+ buckets[bucketIdx].tokens += totalTokens
52
+ buckets[bucketIdx].messageIndices.push(idx)
53
+ })
54
+
55
+ return buckets
56
+ }
57
+
58
+ function buildSeqBuckets(messages) {
59
+ const bucketCount = Math.max(1, Math.ceil(messages.length / BUCKET_MSG_COUNT))
60
+
61
+ const buckets = Array.from({ length: bucketCount }, () => ({
62
+ label: '',
63
+ tokens: 0,
64
+ messageIndices: [],
65
+ }))
66
+
67
+ messages.forEach((msg, idx) => {
68
+ const chars = typeof msg.content === 'string' ? msg.content.length : 0
69
+ const totalTokens = Math.round(chars / 4)
70
+ const bucketIdx = Math.min(Math.floor(idx / BUCKET_MSG_COUNT), bucketCount - 1)
71
+ buckets[bucketIdx].tokens += totalTokens
72
+ buckets[bucketIdx].messageIndices.push(idx)
73
+ })
74
+
75
+ return buckets
76
+ }
77
+
78
+ export default function TokenTimeline({ messages, createdAt, lastUpdatedAt, onScrollToMessage }) {
79
+ const [open, setOpen] = useState(true)
80
+ const [hoveredIdx, setHoveredIdx] = useState(null)
81
+ const scrollContainerRef = useRef(null)
82
+
83
+ const hasRealTokens = useMemo(() => {
84
+ if (!messages) return false
85
+ return messages.some(m => (m.inputTokens || 0) + (m.outputTokens || 0) > 0)
86
+ }, [messages])
87
+
88
+ const buckets = useMemo(() => {
89
+ if (!messages || messages.length === 0) return []
90
+ if (hasRealTokens) return buildTimeBuckets(messages, createdAt, lastUpdatedAt)
91
+ return buildSeqBuckets(messages)
92
+ }, [messages, createdAt, lastUpdatedAt, hasRealTokens])
93
+
94
+ const maxTokens = useMemo(() => Math.max(...buckets.map(b => b.tokens), 1), [buckets])
95
+
96
+ const totalTokens = useMemo(() => buckets.reduce((s, b) => s + b.tokens, 0), [buckets])
97
+
98
+ const durationLabel = useMemo(() => {
99
+ if (hasRealTokens && createdAt && lastUpdatedAt) {
100
+ const ms = new Date(lastUpdatedAt).getTime() - new Date(createdAt).getTime()
101
+ if (ms > 0) return formatDuration(ms)
102
+ }
103
+ if (totalTokens > 0) {
104
+ const estSeconds = totalTokens / AVG_TOKEN_SPEED
105
+ return '~' + formatDuration(estSeconds * 1000)
106
+ }
107
+ return null
108
+ }, [hasRealTokens, createdAt, lastUpdatedAt, totalTokens])
109
+
110
+ const handlePointClick = useCallback((bucket) => {
111
+ if (bucket.messageIndices.length > 0 && onScrollToMessage) {
112
+ onScrollToMessage(bucket.messageIndices[0])
113
+ }
114
+ }, [onScrollToMessage])
115
+
116
+ if (!messages || messages.length === 0 || totalTokens === 0) return null
117
+
118
+ const svgW = PAD_X * 2 + Math.max((buckets.length - 1) * POINT_STEP, 0)
119
+ const svgH = CHART_H + PAD_Y * 2 + (hasRealTokens ? 16 : 4)
120
+
121
+ const points = buckets.map((b, i) => {
122
+ const x = PAD_X + i * POINT_STEP
123
+ const ratio = b.tokens / maxTokens
124
+ const y = PAD_Y + CHART_H - ratio * CHART_H
125
+ return { x, y, ratio, bucket: b }
126
+ })
127
+
128
+ const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
129
+
130
+ const areaPath = points.length > 0
131
+ ? `${linePath} L ${points[points.length - 1].x} ${PAD_Y + CHART_H} L ${points[0].x} ${PAD_Y + CHART_H} Z`
132
+ : ''
133
+
134
+ return (
135
+ <div className="shrink-0" style={{ borderBottom: '1px solid var(--c-border)' }}>
136
+ {/* Toggle button */}
137
+ <button
138
+ onClick={() => setOpen(o => !o)}
139
+ className="flex items-center gap-2 w-full px-4 py-1.5 text-[11px] transition hover:bg-[var(--c-bg3)]"
140
+ style={{ color: 'var(--c-text2)' }}
141
+ >
142
+ <Activity size={12} style={{ color: 'var(--c-accent)' }} />
143
+ <span className="font-medium">Token Timeline</span>
144
+ <span style={{ color: 'var(--c-text3)' }}>
145
+ ({formatNumber(totalTokens)} tokens{!hasRealTokens ? ', est.' : ''})
146
+ </span>
147
+ {durationLabel && (
148
+ <span className="inline-flex items-center gap-1" style={{ color: 'var(--c-text3)' }}>
149
+ <Clock size={10} /> {durationLabel}
150
+ </span>
151
+ )}
152
+ <span className="ml-auto">
153
+ {open ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
154
+ </span>
155
+ </button>
156
+
157
+ {/* Collapsible chart area */}
158
+ {open && (
159
+ <div className="px-2 pb-2 pt-0">
160
+ <div
161
+ ref={scrollContainerRef}
162
+ className="overflow-x-auto scrollbar-thin"
163
+ style={{ scrollBehavior: 'smooth' }}
164
+ >
165
+ <svg
166
+ width={svgW}
167
+ height={svgH}
168
+ style={{ display: 'block', minWidth: svgW }}
169
+ >
170
+ <defs>
171
+ <linearGradient id="tokenAreaGrad" x1="0" y1="0" x2="0" y2="1">
172
+ <stop offset="0%" stopColor="var(--c-accent)" stopOpacity="0.15" />
173
+ <stop offset="100%" stopColor="var(--c-accent)" stopOpacity="0.02" />
174
+ </linearGradient>
175
+ </defs>
176
+
177
+ {/* Baseline */}
178
+ <line
179
+ x1={PAD_X} y1={PAD_Y + CHART_H}
180
+ x2={PAD_X + (buckets.length - 1) * POINT_STEP} y2={PAD_Y + CHART_H}
181
+ stroke="var(--c-border)" strokeWidth="1"
182
+ />
183
+
184
+ {/* Area fill */}
185
+ {areaPath && <path d={areaPath} fill="url(#tokenAreaGrad)" />}
186
+
187
+ {/* Line */}
188
+ {points.length > 1 && (
189
+ <path d={linePath} fill="none" stroke="var(--c-accent)" strokeWidth="1.5" strokeLinejoin="round" />
190
+ )}
191
+
192
+ {/* Data points + labels */}
193
+ {points.map((p, i) => {
194
+ const isHovered = hoveredIdx === i
195
+ const color = tokenColor(p.ratio)
196
+ return (
197
+ <g
198
+ key={i}
199
+ style={{ cursor: p.bucket.tokens > 0 ? 'pointer' : 'default' }}
200
+ onMouseEnter={() => setHoveredIdx(i)}
201
+ onMouseLeave={() => setHoveredIdx(null)}
202
+ onClick={() => handlePointClick(p.bucket)}
203
+ >
204
+ <circle cx={p.x} cy={p.y} r={12} fill="transparent" />
205
+
206
+ <circle
207
+ cx={p.x} cy={p.y}
208
+ r={isHovered ? 5 : 3}
209
+ fill={p.bucket.tokens > 0 ? color : 'var(--c-border)'}
210
+ stroke={isHovered ? 'var(--c-white)' : 'none'}
211
+ strokeWidth={1.5}
212
+ style={{ transition: 'r 0.15s' }}
213
+ />
214
+
215
+ {isHovered && (
216
+ <line
217
+ x1={p.x} y1={p.y} x2={p.x} y2={PAD_Y + CHART_H}
218
+ stroke="var(--c-text3)" strokeWidth="0.5" strokeDasharray="2,2"
219
+ />
220
+ )}
221
+
222
+ {isHovered && p.bucket.tokens > 0 && (
223
+ <g>
224
+ <rect
225
+ x={p.x - 32} y={p.y - 22}
226
+ width={64} height={16} rx={3}
227
+ fill="var(--c-bg)" stroke="var(--c-border)" strokeWidth="0.5"
228
+ />
229
+ <text
230
+ x={p.x} y={p.y - 11}
231
+ textAnchor="middle" fontSize="9" fontFamily="monospace"
232
+ fill="var(--c-white)"
233
+ >
234
+ {formatNumber(p.bucket.tokens)} tok
235
+ </text>
236
+ </g>
237
+ )}
238
+
239
+ {/* X label — only for time-based buckets */}
240
+ {hasRealTokens && p.bucket.label && (
241
+ <text
242
+ x={p.x} y={PAD_Y + CHART_H + 12}
243
+ textAnchor="middle" fontSize="9" fontFamily="monospace"
244
+ fill="var(--c-text3)"
245
+ >
246
+ {p.bucket.label}
247
+ </text>
248
+ )}
249
+ </g>
250
+ )
251
+ })}
252
+ </svg>
253
+ </div>
254
+ </div>
255
+ )}
256
+ </div>
257
+ )
258
+ }
package/ui/src/lib/api.js CHANGED
@@ -234,6 +234,49 @@ export async function fetchMCPs() {
234
234
  return res.json();
235
235
  }
236
236
 
237
+ // ── GSD API ──
238
+
239
+ export async function fetchGSDProjects() {
240
+ const res = await fetch(`${BASE}/api/gsd/projects`);
241
+ return res.json();
242
+ }
243
+
244
+ export async function fetchGSDPhases(folder) {
245
+ const q = new URLSearchParams({ folder });
246
+ const res = await fetch(`${BASE}/api/gsd/phases?${q}`);
247
+ return res.json();
248
+ }
249
+
250
+ export async function fetchGSDPlan(folder, phase) {
251
+ const q = new URLSearchParams({ folder, phase });
252
+ const res = await fetch(`${BASE}/api/gsd/plan?${q}`);
253
+ return res.json();
254
+ }
255
+
256
+ export async function fetchGSDOverview() {
257
+ const res = await fetch(`${BASE}/api/gsd/overview`);
258
+ return res.json();
259
+ }
260
+
261
+ export async function fetchGSDConfig(folder) {
262
+ const q = new URLSearchParams({ folder });
263
+ const res = await fetch(`${BASE}/api/gsd/config?${q}`);
264
+ return res.json();
265
+ }
266
+
267
+ export async function fetchGSDPhaseTokens(folder) {
268
+ const q = new URLSearchParams({ folder });
269
+ const res = await fetch(`${BASE}/api/gsd/phase-tokens?${q}`);
270
+ return res.json();
271
+ }
272
+
273
+ export async function fetchGSDFile(folder, type, phaseDir) {
274
+ const q = new URLSearchParams({ folder, type });
275
+ if (phaseDir) q.set('phase', phaseDir);
276
+ const res = await fetch(`${BASE}/api/gsd/file?${q}`);
277
+ return res.json();
278
+ }
279
+
237
280
  // ── Relay API ──
238
281
 
239
282
  export async function fetchMode() {
@@ -1,27 +1,28 @@
1
1
  export const EDITOR_COLORS = {
2
2
  'cursor': '#f59e0b',
3
- 'windsurf': '#06b6d4',
4
- 'windsurf-next': '#22d3ee',
3
+ 'devin': '#2563eb',
4
+ 'devin-next': '#7c3aed',
5
5
  'antigravity': '#a78bfa',
6
6
  'claude-code': '#f97316',
7
7
  'claude': '#f97316',
8
8
  'vscode': '#3b82f6',
9
9
  'vscode-insiders': '#60a5fa',
10
10
  'zed': '#10b981',
11
- 'opencode': '#ec4899',
12
- 'codex': '#0f766e',
11
+ 'opencode': '#656363',
12
+ 'codex': '#3941FF',
13
13
  'gemini-cli': '#4285f4',
14
14
  'copilot-cli': '#8957e5',
15
15
  'cursor-agent': '#f59e0b',
16
16
  'commandcode': '#e11d48',
17
17
  'goose': '#333333',
18
- 'kiro': '#ff9900',
18
+ 'kiro': '#9046FF',
19
+ 'codebuff': '#44ff00',
19
20
  };
20
21
 
21
22
  export const EDITOR_LABELS = {
22
23
  'cursor': 'Cursor',
23
- 'windsurf': 'Windsurf',
24
- 'windsurf-next': 'Windsurf Next',
24
+ 'devin': 'Devin',
25
+ 'devin-next': 'Devin Next',
25
26
  'antigravity': 'Antigravity',
26
27
  'claude-code': 'Claude Code',
27
28
  'claude': 'Claude Code',
@@ -31,11 +32,12 @@ export const EDITOR_LABELS = {
31
32
  'opencode': 'OpenCode',
32
33
  'codex': 'Codex',
33
34
  'gemini-cli': 'Gemini CLI',
34
- 'copilot-cli': 'Copilot CLI',
35
+ 'copilot-cli': 'GitHub Copilot',
35
36
  'cursor-agent': 'Cursor Agent',
36
37
  'commandcode': 'Command Code',
37
38
  'goose': 'Goose',
38
39
  'kiro': 'Kiro',
40
+ 'codebuff': 'Codebuff',
39
41
  };
40
42
 
41
43
  export function editorColor(src) {
@@ -10,18 +10,6 @@ import PageHeader from '../components/PageHeader'
10
10
 
11
11
  const MONO = 'JetBrains Mono, monospace'
12
12
 
13
- const EDITOR_ICONS = {
14
- 'claude-code': '🟠',
15
- 'cursor': '🟡',
16
- 'windsurf': '🔵',
17
- 'kiro': '🟠',
18
- 'copilot-cli': '🟣',
19
- 'codex': '🟢',
20
- 'gemini-cli': '🔵',
21
- 'goose': '⚫',
22
- '_general': '📄',
23
- }
24
-
25
13
  function formatSize(bytes) {
26
14
  if (bytes < 1024) return bytes + ' B'
27
15
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'