agentlytics 0.2.11 → 0.2.12
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.
- package/cache.js +195 -2
- package/editors/gsd.js +366 -0
- package/package.json +1 -1
- package/server.js +102 -0
- package/ui/src/App.jsx +4 -1
- package/ui/src/components/ChatSidebar.jsx +31 -2
- package/ui/src/components/TokenTimeline.jsx +258 -0
- package/ui/src/lib/api.js +43 -0
- package/ui/src/pages/GSD.jsx +726 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } 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 { Target, FileText, BookOpen, ShieldCheck, ChevronDown, ChevronRight, ListTodo, StickyNote, X, CheckCircle2, Circle, Loader2, HelpCircle, Layers } from 'lucide-react'
|
|
5
|
+
import { fetchGSDProjects, fetchGSDPhases, fetchGSDPlan, fetchGSDOverview, fetchGSDConfig, fetchGSDFile, fetchGSDPhaseTokens } from '../lib/api'
|
|
6
|
+
import { formatCost } from '../lib/constants'
|
|
7
|
+
import AnimatedLoader from '../components/AnimatedLoader'
|
|
8
|
+
import KpiCard from '../components/KpiCard'
|
|
9
|
+
import SectionTitle from '../components/SectionTitle'
|
|
10
|
+
import PageHeader from '../components/PageHeader'
|
|
11
|
+
import { useTheme } from '../lib/theme'
|
|
12
|
+
|
|
13
|
+
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement)
|
|
14
|
+
|
|
15
|
+
const MONO = 'JetBrains Mono, monospace'
|
|
16
|
+
|
|
17
|
+
function formatRelativeTime(ms) {
|
|
18
|
+
if (!ms) return '—'
|
|
19
|
+
const diff = Date.now() - ms
|
|
20
|
+
const m = Math.floor(diff / 60000)
|
|
21
|
+
if (m < 1) return 'just now'
|
|
22
|
+
if (m < 60) return `${m}m ago`
|
|
23
|
+
const h = Math.floor(m / 60)
|
|
24
|
+
if (h < 24) return `${h}h ago`
|
|
25
|
+
const d = Math.floor(h / 24)
|
|
26
|
+
return `${d}d ago`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function statusColor(status) {
|
|
30
|
+
if (status === 'completed') return '#22c55e'
|
|
31
|
+
if (status === 'executing') return '#f59e0b'
|
|
32
|
+
return 'var(--c-text3)'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ProgressBar({ value, total, color = '#6366f1' }) {
|
|
36
|
+
const pct = total > 0 ? Math.round((value / total) * 100) : 0
|
|
37
|
+
return (
|
|
38
|
+
<div className="flex items-center gap-2">
|
|
39
|
+
<div className="flex-1 h-1 rounded-full overflow-hidden" style={{ background: 'var(--c-bg3)' }}>
|
|
40
|
+
<div className="h-full rounded-full transition-all" style={{ width: `${pct}%`, background: color }} />
|
|
41
|
+
</div>
|
|
42
|
+
<span className="text-[10px] tabular-nums" style={{ color: 'var(--c-text3)', fontFamily: MONO, minWidth: 36 }}>
|
|
43
|
+
{value}/{total}
|
|
44
|
+
</span>
|
|
45
|
+
</div>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ============================================================
|
|
50
|
+
// File sidebar (slide from right — generic markdown viewer)
|
|
51
|
+
// ============================================================
|
|
52
|
+
|
|
53
|
+
function FileSidebar({ title, subtitle, content, loading, onClose }) {
|
|
54
|
+
const scrollRef = useRef(null)
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<>
|
|
58
|
+
<div
|
|
59
|
+
className="fixed inset-0 z-40 transition-opacity"
|
|
60
|
+
style={{ background: 'rgba(0,0,0,0.3)' }}
|
|
61
|
+
onClick={onClose}
|
|
62
|
+
/>
|
|
63
|
+
<div
|
|
64
|
+
className="fixed top-0 right-0 bottom-0 z-50 flex flex-col shadow-2xl sidebar-slide-in cursor-default"
|
|
65
|
+
style={{ width: 'min(620px, 92vw)', background: 'var(--c-bg)', borderLeft: '1px solid var(--c-border)' }}
|
|
66
|
+
>
|
|
67
|
+
{/* Header */}
|
|
68
|
+
<div className="flex items-center gap-3 px-4 py-3 shrink-0" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
69
|
+
<button onClick={onClose} className="p-1 rounded transition hover:bg-[var(--c-bg3)]" style={{ color: 'var(--c-text2)' }}>
|
|
70
|
+
<X size={14} />
|
|
71
|
+
</button>
|
|
72
|
+
<div className="flex-1 min-w-0">
|
|
73
|
+
<div className="text-[13px] font-semibold" style={{ color: 'var(--c-white)' }}>{title}</div>
|
|
74
|
+
{subtitle && (
|
|
75
|
+
<div className="text-[11px] capitalize" style={{ color: 'var(--c-text2)', fontFamily: MONO }}>{subtitle}</div>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
{/* Content */}
|
|
81
|
+
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-thin px-4 py-3">
|
|
82
|
+
{loading && (
|
|
83
|
+
<div className="text-[12px] py-12 text-center" style={{ color: 'var(--c-text3)' }}>Loading…</div>
|
|
84
|
+
)}
|
|
85
|
+
{!loading && content === null && (
|
|
86
|
+
<div className="text-[12px] py-12 text-center" style={{ color: 'var(--c-text3)' }}>File not found.</div>
|
|
87
|
+
)}
|
|
88
|
+
{!loading && content !== null && (
|
|
89
|
+
<pre
|
|
90
|
+
className="text-[11px] leading-relaxed whitespace-pre-wrap break-words"
|
|
91
|
+
style={{ color: 'var(--c-text)', fontFamily: MONO }}
|
|
92
|
+
>
|
|
93
|
+
{content}
|
|
94
|
+
</pre>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ============================================================
|
|
103
|
+
// Config popover
|
|
104
|
+
// ============================================================
|
|
105
|
+
|
|
106
|
+
const CONFIG_LABELS = {
|
|
107
|
+
mode: 'Mode',
|
|
108
|
+
model_profile: 'Model profile',
|
|
109
|
+
granularity: 'Granularity',
|
|
110
|
+
parallelization: 'Parallelization',
|
|
111
|
+
commit_docs: 'Commit docs',
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const WORKFLOW_LABELS = {
|
|
115
|
+
research: 'Research',
|
|
116
|
+
plan_check: 'Plan check',
|
|
117
|
+
verifier: 'Verifier',
|
|
118
|
+
nyquist_validation: 'Nyquist',
|
|
119
|
+
auto_advance: 'Auto advance',
|
|
120
|
+
ui_phase: 'UI phase',
|
|
121
|
+
skip_discuss: 'Skip discuss',
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function ConfigPopover({ folder, anchor, onClose }) {
|
|
125
|
+
const [config, setConfig] = useState(undefined)
|
|
126
|
+
const ref = useRef(null)
|
|
127
|
+
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
fetchGSDConfig(folder).then(setConfig).catch(() => setConfig(null))
|
|
130
|
+
}, [folder])
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
function handleClick(e) {
|
|
134
|
+
if (ref.current && !ref.current.contains(e.target)) onClose()
|
|
135
|
+
}
|
|
136
|
+
document.addEventListener('mousedown', handleClick)
|
|
137
|
+
return () => document.removeEventListener('mousedown', handleClick)
|
|
138
|
+
}, [onClose])
|
|
139
|
+
|
|
140
|
+
// Position fixed relative to the anchor button rect
|
|
141
|
+
const top = anchor ? anchor.bottom + 6 : 0
|
|
142
|
+
const right = anchor ? window.innerWidth - anchor.right : 0
|
|
143
|
+
|
|
144
|
+
function val(v) {
|
|
145
|
+
if (v === true) return <span style={{ color: '#22c55e' }}>on</span>
|
|
146
|
+
if (v === false) return <span style={{ color: 'var(--c-text3)' }}>off</span>
|
|
147
|
+
return <span style={{ color: 'var(--c-white)' }}>{String(v)}</span>
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<div
|
|
152
|
+
ref={ref}
|
|
153
|
+
className="p-3 shadow-xl text-[11px]"
|
|
154
|
+
style={{
|
|
155
|
+
position: 'fixed',
|
|
156
|
+
top,
|
|
157
|
+
right,
|
|
158
|
+
zIndex: 9999,
|
|
159
|
+
width: 240,
|
|
160
|
+
background: 'var(--c-bg)',
|
|
161
|
+
border: '1px solid var(--c-border)',
|
|
162
|
+
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
|
|
163
|
+
}}
|
|
164
|
+
onClick={e => e.stopPropagation()}
|
|
165
|
+
>
|
|
166
|
+
{config === undefined && (
|
|
167
|
+
<div style={{ color: 'var(--c-text3)' }}>Loading…</div>
|
|
168
|
+
)}
|
|
169
|
+
{config === null && (
|
|
170
|
+
<div style={{ color: 'var(--c-text3)' }}>No config.json found.</div>
|
|
171
|
+
)}
|
|
172
|
+
{config && (
|
|
173
|
+
<div className="space-y-2">
|
|
174
|
+
{/* Top-level settings */}
|
|
175
|
+
<div className="space-y-1">
|
|
176
|
+
{Object.entries(CONFIG_LABELS).map(([k, label]) => {
|
|
177
|
+
if (!(k in config)) return null
|
|
178
|
+
return (
|
|
179
|
+
<div key={k} className="flex items-center justify-between gap-2">
|
|
180
|
+
<span style={{ color: 'var(--c-text2)' }}>{label}</span>
|
|
181
|
+
{val(config[k])}
|
|
182
|
+
</div>
|
|
183
|
+
)
|
|
184
|
+
})}
|
|
185
|
+
</div>
|
|
186
|
+
{/* Workflow toggles */}
|
|
187
|
+
{config.workflow && (
|
|
188
|
+
<>
|
|
189
|
+
<div className="border-t pt-2" style={{ borderColor: 'var(--c-border)', color: 'var(--c-text3)' }}>workflow</div>
|
|
190
|
+
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
|
|
191
|
+
{Object.entries(WORKFLOW_LABELS).map(([k, label]) => {
|
|
192
|
+
if (!(k in config.workflow)) return null
|
|
193
|
+
return (
|
|
194
|
+
<div key={k} className="flex items-center justify-between gap-1">
|
|
195
|
+
<span style={{ color: 'var(--c-text2)' }}>{label}</span>
|
|
196
|
+
{val(config.workflow[k])}
|
|
197
|
+
</div>
|
|
198
|
+
)
|
|
199
|
+
})}
|
|
200
|
+
</div>
|
|
201
|
+
</>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ============================================================
|
|
210
|
+
// Phase row
|
|
211
|
+
// ============================================================
|
|
212
|
+
|
|
213
|
+
function PhaseRow({ phase, tokenData, onOpenFile }) {
|
|
214
|
+
// Phase comes from SQLite cache — flat fields (total_tasks, completed_tasks, has_plan, etc.)
|
|
215
|
+
const totalTasks = phase.total_tasks ?? 0
|
|
216
|
+
const completedTasks = phase.completed_tasks ?? 0
|
|
217
|
+
const hasPlan = !!phase.has_plan
|
|
218
|
+
const hasResearch = !!phase.has_research
|
|
219
|
+
const hasVerification = !!phase.has_verification
|
|
220
|
+
const status = phase.status ?? 'planned'
|
|
221
|
+
|
|
222
|
+
const StatusIcon = status === 'completed'
|
|
223
|
+
? CheckCircle2
|
|
224
|
+
: status === 'executing'
|
|
225
|
+
? Loader2
|
|
226
|
+
: Circle
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<div
|
|
230
|
+
className="flex items-center px-3 py-2 border-b text-[12px] hover:bg-[var(--c-bg3)] transition"
|
|
231
|
+
style={{ borderColor: 'var(--c-border)', gap: 0 }}
|
|
232
|
+
>
|
|
233
|
+
{/* Status icon — 28px */}
|
|
234
|
+
<div style={{ width: 28, flexShrink: 0 }}>
|
|
235
|
+
<StatusIcon
|
|
236
|
+
size={12}
|
|
237
|
+
style={{ color: statusColor(status) }}
|
|
238
|
+
className={status === 'executing' ? 'animate-spin' : ''}
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
{/* Phase number — 32px */}
|
|
243
|
+
<div style={{ width: 32, flexShrink: 0 }}>
|
|
244
|
+
<span className="text-[10px] font-bold tabular-nums" style={{ color: 'var(--c-text3)', fontFamily: MONO }}>
|
|
245
|
+
{phase.phase_number ?? '?'}
|
|
246
|
+
</span>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
{/* Phase name — flex-1 */}
|
|
250
|
+
<div className="flex-1 min-w-0 pr-3">
|
|
251
|
+
<span className="block truncate capitalize text-[12px]" style={{ color: 'var(--c-white)' }}>
|
|
252
|
+
{phase.phase_name}
|
|
253
|
+
</span>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
{/* Progress bar — always 100px */}
|
|
257
|
+
<div style={{ width: 100, flexShrink: 0, paddingRight: 12 }}>
|
|
258
|
+
{totalTasks > 0
|
|
259
|
+
? <ProgressBar value={completedTasks} total={totalTasks} color={statusColor(status)} />
|
|
260
|
+
: null}
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
{/* Artifacts — always 88px */}
|
|
264
|
+
<div style={{ width: 88, flexShrink: 0 }} className="flex items-center gap-1">
|
|
265
|
+
{hasPlan && (
|
|
266
|
+
<button
|
|
267
|
+
onClick={() => onOpenFile(phase, 'plan')}
|
|
268
|
+
className="flex items-center gap-1 px-1.5 py-px text-[10px] transition hover:opacity-80 cursor-pointer"
|
|
269
|
+
style={{ background: 'rgba(99,102,241,0.12)', color: '#818cf8' }}
|
|
270
|
+
title="View PLAN.md"
|
|
271
|
+
>
|
|
272
|
+
<FileText size={9} />
|
|
273
|
+
plan
|
|
274
|
+
</button>
|
|
275
|
+
)}
|
|
276
|
+
{hasResearch && (
|
|
277
|
+
<button
|
|
278
|
+
onClick={() => onOpenFile(phase, 'research')}
|
|
279
|
+
className="px-1 py-px transition hover:opacity-80 cursor-pointer"
|
|
280
|
+
style={{ background: 'rgba(245,158,11,0.1)', color: '#f59e0b' }}
|
|
281
|
+
title="View RESEARCH.md"
|
|
282
|
+
>
|
|
283
|
+
<BookOpen size={9} />
|
|
284
|
+
</button>
|
|
285
|
+
)}
|
|
286
|
+
{hasVerification && (
|
|
287
|
+
<button
|
|
288
|
+
onClick={() => onOpenFile(phase, 'verification')}
|
|
289
|
+
className="px-1 py-px transition hover:opacity-80 cursor-pointer"
|
|
290
|
+
style={{ background: 'rgba(34,197,94,0.1)', color: '#22c55e' }}
|
|
291
|
+
title="View VERIFICATION.md"
|
|
292
|
+
>
|
|
293
|
+
<ShieldCheck size={9} />
|
|
294
|
+
</button>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
{/* Est. cost — always 64px */}
|
|
299
|
+
<div style={{ width: 64, flexShrink: 0, textAlign: 'right' }}>
|
|
300
|
+
{tokenData && tokenData.cost > 0 ? (
|
|
301
|
+
<span className="text-[10px] tabular-nums" style={{ color: 'var(--c-text2)', fontFamily: MONO }}>
|
|
302
|
+
{formatCost(tokenData.cost)}
|
|
303
|
+
</span>
|
|
304
|
+
) : null}
|
|
305
|
+
</div>
|
|
306
|
+
|
|
307
|
+
{/* Time — always 56px */}
|
|
308
|
+
<div style={{ width: 56, flexShrink: 0, textAlign: 'right' }}>
|
|
309
|
+
<span className="text-[10px]" style={{ color: 'var(--c-text3)' }}>
|
|
310
|
+
{formatRelativeTime(phase.last_modified)}
|
|
311
|
+
</span>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ============================================================
|
|
318
|
+
// Project card
|
|
319
|
+
// ============================================================
|
|
320
|
+
|
|
321
|
+
function projectBorderColor(project) {
|
|
322
|
+
if (project.total_phases === 0) return 'var(--c-border)'
|
|
323
|
+
if (project.completed_phases === project.total_phases) return '#22c55e'
|
|
324
|
+
if (project.completed_phases > 0) return '#f59e0b'
|
|
325
|
+
return 'var(--c-border)'
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function ProjectCard({ project, isExpanded, onToggle, onOpenFile, onOpenConfig, onOpenState }) {
|
|
329
|
+
const [phases, setPhases] = useState(null)
|
|
330
|
+
const [tokenMap, setTokenMap] = useState(null) // phase id → token data
|
|
331
|
+
const configBtnRef = useRef(null)
|
|
332
|
+
const stateBtnRef = useRef(null)
|
|
333
|
+
const pct = project.total_phases > 0
|
|
334
|
+
? Math.round((project.completed_phases / project.total_phases) * 100)
|
|
335
|
+
: 0
|
|
336
|
+
const remaining = project.total_phases - project.completed_phases
|
|
337
|
+
|
|
338
|
+
useEffect(() => {
|
|
339
|
+
if (isExpanded && phases === null) {
|
|
340
|
+
fetchGSDPhases(project.folder).then(setPhases)
|
|
341
|
+
fetchGSDPhaseTokens(project.folder).then(rows => {
|
|
342
|
+
const map = {}
|
|
343
|
+
for (const r of rows) map[r.id] = r
|
|
344
|
+
setTokenMap(map)
|
|
345
|
+
}).catch(() => setTokenMap({}))
|
|
346
|
+
}
|
|
347
|
+
}, [isExpanded, phases, project.folder])
|
|
348
|
+
|
|
349
|
+
return (
|
|
350
|
+
<div
|
|
351
|
+
className="card overflow-hidden"
|
|
352
|
+
style={{ borderLeft: `2px solid ${projectBorderColor(project)}` }}
|
|
353
|
+
>
|
|
354
|
+
<div className="flex items-center w-full px-3 py-3" style={{ gap: 0 }}>
|
|
355
|
+
{/* Chevron — 24px */}
|
|
356
|
+
<button
|
|
357
|
+
onClick={onToggle}
|
|
358
|
+
className="flex items-center justify-center hover:bg-[var(--c-bg3)] transition rounded cursor-pointer"
|
|
359
|
+
style={{ width: 24, height: 24, flexShrink: 0, color: 'var(--c-text3)' }}
|
|
360
|
+
>
|
|
361
|
+
{isExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
|
362
|
+
</button>
|
|
363
|
+
|
|
364
|
+
{/* Name — flex-1, clickable */}
|
|
365
|
+
<button
|
|
366
|
+
onClick={onToggle}
|
|
367
|
+
className="flex-1 min-w-0 text-left px-2 cursor-pointer"
|
|
368
|
+
>
|
|
369
|
+
<div className="flex items-center gap-2">
|
|
370
|
+
<span className="text-[13px] font-semibold truncate" style={{ color: 'var(--c-white)' }}>
|
|
371
|
+
{project.name}
|
|
372
|
+
</span>
|
|
373
|
+
{project.milestone && (
|
|
374
|
+
<span className="text-[10px] px-1.5 py-px shrink-0" style={{ background: 'rgba(99,102,241,0.12)', color: '#818cf8' }}>
|
|
375
|
+
{project.milestone}
|
|
376
|
+
</span>
|
|
377
|
+
)}
|
|
378
|
+
</div>
|
|
379
|
+
<div className="text-[10px] truncate mt-0.5" style={{ color: 'var(--c-text3)', fontFamily: MONO }}>
|
|
380
|
+
{project.folder}
|
|
381
|
+
</div>
|
|
382
|
+
</button>
|
|
383
|
+
|
|
384
|
+
{/* % — 36px */}
|
|
385
|
+
<div style={{ width: 36, flexShrink: 0, textAlign: 'right' }}>
|
|
386
|
+
<span
|
|
387
|
+
className="text-[11px] font-bold tabular-nums"
|
|
388
|
+
style={{ color: pct === 100 ? '#22c55e' : pct > 0 ? '#f59e0b' : 'var(--c-text3)', fontFamily: MONO }}
|
|
389
|
+
>
|
|
390
|
+
{pct}%
|
|
391
|
+
</span>
|
|
392
|
+
</div>
|
|
393
|
+
|
|
394
|
+
{/* Progress bar — 112px */}
|
|
395
|
+
<div style={{ width: 112, flexShrink: 0, padding: '0 10px' }}>
|
|
396
|
+
<ProgressBar
|
|
397
|
+
value={project.completed_phases}
|
|
398
|
+
total={project.total_phases}
|
|
399
|
+
color={pct === 100 ? '#22c55e' : '#6366f1'}
|
|
400
|
+
/>
|
|
401
|
+
</div>
|
|
402
|
+
|
|
403
|
+
{/* Status pills — 80px */}
|
|
404
|
+
<div style={{ width: 80, flexShrink: 0 }} className="flex items-center gap-1">
|
|
405
|
+
{project.completed_phases > 0 && (
|
|
406
|
+
<span className="flex items-center gap-1 text-[10px] px-1.5 py-px" style={{ background: 'rgba(34,197,94,0.08)', color: '#22c55e' }}>
|
|
407
|
+
<CheckCircle2 size={9} /> {project.completed_phases}
|
|
408
|
+
</span>
|
|
409
|
+
)}
|
|
410
|
+
{remaining > 0 && (
|
|
411
|
+
<span className="flex items-center gap-1 text-[10px] px-1.5 py-px" style={{ background: 'rgba(255,255,255,0.04)', color: 'var(--c-text3)' }}>
|
|
412
|
+
<Circle size={9} /> {remaining}
|
|
413
|
+
</span>
|
|
414
|
+
)}
|
|
415
|
+
</div>
|
|
416
|
+
|
|
417
|
+
{/* Counters — 52px */}
|
|
418
|
+
<div style={{ width: 52, flexShrink: 0 }} className="flex items-center gap-2">
|
|
419
|
+
{project.todos > 0 && (
|
|
420
|
+
<span className="flex items-center gap-1 text-[10px]" style={{ color: 'var(--c-text3)' }} title="Todos / Seeds">
|
|
421
|
+
<ListTodo size={10} /> {project.todos}
|
|
422
|
+
</span>
|
|
423
|
+
)}
|
|
424
|
+
{project.notes > 0 && (
|
|
425
|
+
<span className="flex items-center gap-1 text-[10px]" style={{ color: 'var(--c-text3)' }} title="Quick tasks">
|
|
426
|
+
<StickyNote size={10} /> {project.notes}
|
|
427
|
+
</span>
|
|
428
|
+
)}
|
|
429
|
+
</div>
|
|
430
|
+
|
|
431
|
+
{/* Est. cost — 60px */}
|
|
432
|
+
<div style={{ width: 60, flexShrink: 0, textAlign: 'right' }}>
|
|
433
|
+
<span className="text-[11px] tabular-nums" style={{ color: project.total_cost > 0 ? '#a78bfa' : 'var(--c-text3)', fontFamily: MONO }}>
|
|
434
|
+
{project.total_cost > 0 ? formatCost(project.total_cost) : '—'}
|
|
435
|
+
</span>
|
|
436
|
+
</div>
|
|
437
|
+
|
|
438
|
+
{/* Updated — 56px */}
|
|
439
|
+
<div style={{ width: 56, flexShrink: 0, textAlign: 'right' }}>
|
|
440
|
+
<span className="text-[10px]" style={{ color: 'var(--c-text3)' }}>
|
|
441
|
+
{formatRelativeTime(project.last_modified)}
|
|
442
|
+
</span>
|
|
443
|
+
</div>
|
|
444
|
+
|
|
445
|
+
{/* State + Config buttons — 52px */}
|
|
446
|
+
<div style={{ width: 52, flexShrink: 0 }} className="flex items-center justify-end gap-0.5" onClick={e => e.stopPropagation()}>
|
|
447
|
+
<button
|
|
448
|
+
ref={stateBtnRef}
|
|
449
|
+
onClick={() => onOpenState(project.folder, project.name)}
|
|
450
|
+
className="p-1 rounded transition hover:bg-[var(--c-bg3)] cursor-pointer"
|
|
451
|
+
style={{ color: 'var(--c-text3)' }}
|
|
452
|
+
title="View STATE.md"
|
|
453
|
+
>
|
|
454
|
+
<Layers size={13} />
|
|
455
|
+
</button>
|
|
456
|
+
<button
|
|
457
|
+
ref={configBtnRef}
|
|
458
|
+
onClick={() => onOpenConfig(project.folder, configBtnRef.current?.getBoundingClientRect())}
|
|
459
|
+
className="p-1 rounded transition hover:bg-[var(--c-bg3)] cursor-pointer"
|
|
460
|
+
style={{ color: 'var(--c-text3)' }}
|
|
461
|
+
title="GSD config"
|
|
462
|
+
>
|
|
463
|
+
<HelpCircle size={13} />
|
|
464
|
+
</button>
|
|
465
|
+
</div>
|
|
466
|
+
</div>
|
|
467
|
+
|
|
468
|
+
{isExpanded && (
|
|
469
|
+
<div className="border-t" style={{ borderColor: 'var(--c-border)' }}>
|
|
470
|
+
<div
|
|
471
|
+
className="flex items-center px-3 py-1 text-[10px]"
|
|
472
|
+
style={{ background: 'var(--c-bg2)', color: 'var(--c-text3)', gap: 0 }}
|
|
473
|
+
>
|
|
474
|
+
<div style={{ width: 28, flexShrink: 0 }} />
|
|
475
|
+
<div style={{ width: 32, flexShrink: 0 }}>#</div>
|
|
476
|
+
<div className="flex-1 pr-3">phase</div>
|
|
477
|
+
<div style={{ width: 100, flexShrink: 0, paddingRight: 12 }}>plans</div>
|
|
478
|
+
<div style={{ width: 88, flexShrink: 0 }}>artifacts</div>
|
|
479
|
+
<div style={{ width: 64, flexShrink: 0, textAlign: 'right' }}>est. cost</div>
|
|
480
|
+
<div style={{ width: 56, flexShrink: 0, textAlign: 'right' }}>updated</div>
|
|
481
|
+
</div>
|
|
482
|
+
|
|
483
|
+
{phases === null
|
|
484
|
+
? <div className="px-4 py-3 text-[12px]" style={{ color: 'var(--c-text3)' }}>Loading phases…</div>
|
|
485
|
+
: phases.length === 0
|
|
486
|
+
? <div className="px-4 py-3 text-[12px]" style={{ color: 'var(--c-text3)' }}>No phases found.</div>
|
|
487
|
+
: phases.map(phase => (
|
|
488
|
+
<PhaseRow
|
|
489
|
+
key={phase.id}
|
|
490
|
+
phase={phase}
|
|
491
|
+
tokenData={tokenMap?.[phase.id] ?? null}
|
|
492
|
+
onOpenFile={(ph, type) => onOpenFile(ph, project.folder, type)}
|
|
493
|
+
/>
|
|
494
|
+
))
|
|
495
|
+
}
|
|
496
|
+
</div>
|
|
497
|
+
)}
|
|
498
|
+
</div>
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ============================================================
|
|
503
|
+
// Main page
|
|
504
|
+
// ============================================================
|
|
505
|
+
|
|
506
|
+
export default function GSD() {
|
|
507
|
+
const { dark } = useTheme()
|
|
508
|
+
const [projects, setProjects] = useState(null)
|
|
509
|
+
const [overview, setOverview] = useState(null)
|
|
510
|
+
const [allTokens, setAllTokens] = useState(null) // { totalCost }
|
|
511
|
+
const [expandedFolder, setExpandedFolder] = useState(null)
|
|
512
|
+
const [fileSidebar, setFileSidebar] = useState(null) // { title, subtitle, content, loading }
|
|
513
|
+
const [configPopover, setConfigPopover] = useState(null) // { folder, anchor }
|
|
514
|
+
const [loading, setLoading] = useState(true)
|
|
515
|
+
|
|
516
|
+
const txtColor = dark ? '#888' : '#555'
|
|
517
|
+
const txtDim = dark ? '#555' : '#999'
|
|
518
|
+
const gridColor = dark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.06)'
|
|
519
|
+
|
|
520
|
+
useEffect(() => {
|
|
521
|
+
Promise.all([fetchGSDProjects(), fetchGSDOverview()])
|
|
522
|
+
.then(([p, o]) => {
|
|
523
|
+
setProjects(p)
|
|
524
|
+
setOverview(o)
|
|
525
|
+
setLoading(false)
|
|
526
|
+
// total_cost is already computed server-side per project
|
|
527
|
+
const totalCost = (p || []).reduce((s, proj) => s + (proj.total_cost || 0), 0)
|
|
528
|
+
setAllTokens({ totalCost })
|
|
529
|
+
})
|
|
530
|
+
.catch(() => setLoading(false))
|
|
531
|
+
}, [])
|
|
532
|
+
|
|
533
|
+
function handleOpenConfig(folder, anchor) {
|
|
534
|
+
setConfigPopover(prev => prev?.folder === folder ? null : { folder, anchor })
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function handleOpenFile(phase, folder, type) {
|
|
538
|
+
const phaseDir = phase.id?.split('::')?.[1] || ''
|
|
539
|
+
const phaseName = phase.phase_name ?? phase.name ?? ''
|
|
540
|
+
const titles = { plan: 'PLAN.md', research: 'RESEARCH.md', verification: 'VERIFICATION.md', summary: 'SUMMARY.md' }
|
|
541
|
+
const title = titles[type] ?? type.toUpperCase() + '.md'
|
|
542
|
+
|
|
543
|
+
setFileSidebar({ title, subtitle: phaseName, content: null, loading: true })
|
|
544
|
+
|
|
545
|
+
if (type === 'plan') {
|
|
546
|
+
fetchGSDPlan(folder, phaseDir)
|
|
547
|
+
.then(d => setFileSidebar(prev => prev ? { ...prev, content: d?.content ?? null, loading: false } : null))
|
|
548
|
+
.catch(() => setFileSidebar(prev => prev ? { ...prev, loading: false } : null))
|
|
549
|
+
} else {
|
|
550
|
+
fetchGSDFile(folder, type, phaseDir)
|
|
551
|
+
.then(d => setFileSidebar(prev => prev ? { ...prev, content: d?.content ?? null, loading: false } : null))
|
|
552
|
+
.catch(() => setFileSidebar(prev => prev ? { ...prev, loading: false } : null))
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function handleOpenState(folder, projectName) {
|
|
557
|
+
setFileSidebar({ title: 'STATE.md', subtitle: projectName, content: null, loading: true })
|
|
558
|
+
fetchGSDFile(folder, 'state')
|
|
559
|
+
.then(d => setFileSidebar(prev => prev ? { ...prev, content: d?.content ?? null, loading: false } : null))
|
|
560
|
+
.catch(() => setFileSidebar(prev => prev ? { ...prev, loading: false } : null))
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (loading) return <AnimatedLoader label="Loading GSD projects..." />
|
|
564
|
+
|
|
565
|
+
if (!projects || projects.length === 0) {
|
|
566
|
+
return (
|
|
567
|
+
<div className="fade-in space-y-3">
|
|
568
|
+
<PageHeader icon={Target} title="GSD Workflow" />
|
|
569
|
+
<div className="card p-8 text-center space-y-3">
|
|
570
|
+
<Target size={32} style={{ color: 'var(--c-text3)', margin: '0 auto' }} />
|
|
571
|
+
<div className="text-[14px] font-semibold" style={{ color: 'var(--c-white)' }}>No GSD projects found</div>
|
|
572
|
+
<div className="text-[12px] max-w-sm mx-auto" style={{ color: 'var(--c-text2)' }}>
|
|
573
|
+
GSD (Get Shit Done) is a structured AI workflow system that stores project plans and phases
|
|
574
|
+
inside a <code style={{ fontFamily: MONO }}>.planning/</code> directory. Run a data scan after
|
|
575
|
+
initializing a GSD project and it will appear here.
|
|
576
|
+
</div>
|
|
577
|
+
</div>
|
|
578
|
+
</div>
|
|
579
|
+
)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const completionRate = overview && overview.totalPhases > 0
|
|
583
|
+
? Math.round((overview.completedPhases / overview.totalPhases) * 100)
|
|
584
|
+
: 0
|
|
585
|
+
|
|
586
|
+
const phaseStatuses = {
|
|
587
|
+
completed: overview?.completedPhases ?? 0,
|
|
588
|
+
executing: overview?.executingPhases ?? 0,
|
|
589
|
+
planned: overview?.plannedPhases ?? 0,
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const statusDonutData = {
|
|
593
|
+
labels: ['Completed', 'Executing', 'Planned'],
|
|
594
|
+
datasets: [{
|
|
595
|
+
data: [phaseStatuses.completed, phaseStatuses.executing, phaseStatuses.planned],
|
|
596
|
+
backgroundColor: ['#22c55e', '#f59e0b', 'rgba(255,255,255,0.08)'],
|
|
597
|
+
borderWidth: 0,
|
|
598
|
+
}],
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const topProjects = [...projects].sort((a, b) => b.total_phases - a.total_phases).slice(0, 8)
|
|
602
|
+
const projectBarData = {
|
|
603
|
+
labels: topProjects.map(p => p.name.length > 16 ? p.name.slice(0, 15) + '…' : p.name),
|
|
604
|
+
datasets: [
|
|
605
|
+
{
|
|
606
|
+
label: 'Completed',
|
|
607
|
+
data: topProjects.map(p => p.completed_phases),
|
|
608
|
+
backgroundColor: '#22c55e',
|
|
609
|
+
borderRadius: 2,
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
label: 'Remaining',
|
|
613
|
+
data: topProjects.map(p => p.total_phases - p.completed_phases),
|
|
614
|
+
backgroundColor: 'rgba(99,102,241,0.3)',
|
|
615
|
+
borderRadius: 2,
|
|
616
|
+
},
|
|
617
|
+
],
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const donutOpts = {
|
|
621
|
+
responsive: true, maintainAspectRatio: false, cutout: '72%',
|
|
622
|
+
plugins: {
|
|
623
|
+
legend: { display: false },
|
|
624
|
+
tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
|
|
625
|
+
},
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const barOpts = {
|
|
629
|
+
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
|
|
630
|
+
scales: {
|
|
631
|
+
x: { stacked: true, grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 8, family: MONO } } },
|
|
632
|
+
y: { stacked: true, grid: { display: false }, ticks: { color: txtColor, font: { size: 8, family: MONO } } },
|
|
633
|
+
},
|
|
634
|
+
plugins: {
|
|
635
|
+
legend: { display: false },
|
|
636
|
+
tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
|
|
637
|
+
},
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return (
|
|
641
|
+
<div className="fade-in space-y-3">
|
|
642
|
+
<PageHeader icon={Target} title="GSD Workflow" />
|
|
643
|
+
|
|
644
|
+
{/* KPIs */}
|
|
645
|
+
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(90px, 1fr))' }}>
|
|
646
|
+
<KpiCard label="projects" value={overview?.totalProjects ?? projects.length} />
|
|
647
|
+
<KpiCard label="total phases" value={overview?.totalPhases ?? '—'} />
|
|
648
|
+
<KpiCard label="completed" value={overview?.completedPhases ?? '—'} />
|
|
649
|
+
<KpiCard label="completion" value={overview?.totalPhases > 0 ? `${completionRate}%` : '—'} />
|
|
650
|
+
<KpiCard label="total cost" value={allTokens ? formatCost(allTokens.totalCost) : '—'} />
|
|
651
|
+
</div>
|
|
652
|
+
|
|
653
|
+
{/* Charts */}
|
|
654
|
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
|
655
|
+
<div className="card p-3">
|
|
656
|
+
<SectionTitle>phase status</SectionTitle>
|
|
657
|
+
<div className="flex items-center gap-4">
|
|
658
|
+
<div style={{ height: 110, width: 110, flexShrink: 0 }}>
|
|
659
|
+
<Doughnut data={statusDonutData} options={donutOpts} />
|
|
660
|
+
</div>
|
|
661
|
+
<div className="space-y-2 text-[11px]">
|
|
662
|
+
{[
|
|
663
|
+
{ label: 'Completed', count: phaseStatuses.completed, color: '#22c55e' },
|
|
664
|
+
{ label: 'Executing', count: phaseStatuses.executing, color: '#f59e0b' },
|
|
665
|
+
{ label: 'Planned', count: phaseStatuses.planned, color: 'var(--c-text3)' },
|
|
666
|
+
].map(({ label, count, color }) => (
|
|
667
|
+
<div key={label} className="flex items-center gap-2">
|
|
668
|
+
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: color }} />
|
|
669
|
+
<span style={{ color: 'var(--c-text2)' }}>{label}</span>
|
|
670
|
+
<span className="ml-auto font-bold tabular-nums" style={{ color, fontFamily: MONO }}>{count}</span>
|
|
671
|
+
</div>
|
|
672
|
+
))}
|
|
673
|
+
</div>
|
|
674
|
+
</div>
|
|
675
|
+
</div>
|
|
676
|
+
|
|
677
|
+
<div className="card p-3 lg:col-span-2">
|
|
678
|
+
<SectionTitle>projects <span style={{ color: 'var(--c-text3)' }}>by phases</span></SectionTitle>
|
|
679
|
+
<div style={{ height: 130 }}>
|
|
680
|
+
<Bar data={projectBarData} options={barOpts} />
|
|
681
|
+
</div>
|
|
682
|
+
<div className="flex items-center gap-4 mt-2 text-[10px]" style={{ color: 'var(--c-text3)' }}>
|
|
683
|
+
<span className="flex items-center gap-1.5"><span className="inline-block w-2 h-2 rounded-sm" style={{ background: '#22c55e' }} /> Completed</span>
|
|
684
|
+
<span className="flex items-center gap-1.5"><span className="inline-block w-2 h-2 rounded-sm" style={{ background: 'rgba(99,102,241,0.3)' }} /> Remaining</span>
|
|
685
|
+
</div>
|
|
686
|
+
</div>
|
|
687
|
+
</div>
|
|
688
|
+
|
|
689
|
+
{/* Project cards */}
|
|
690
|
+
<div className="space-y-2">
|
|
691
|
+
<SectionTitle>projects</SectionTitle>
|
|
692
|
+
{projects.map(project => (
|
|
693
|
+
<ProjectCard
|
|
694
|
+
key={project.folder}
|
|
695
|
+
project={project}
|
|
696
|
+
isExpanded={expandedFolder === project.folder}
|
|
697
|
+
onToggle={() => setExpandedFolder(prev => prev === project.folder ? null : project.folder)}
|
|
698
|
+
onOpenFile={handleOpenFile}
|
|
699
|
+
onOpenConfig={handleOpenConfig}
|
|
700
|
+
onOpenState={handleOpenState}
|
|
701
|
+
/>
|
|
702
|
+
))}
|
|
703
|
+
</div>
|
|
704
|
+
|
|
705
|
+
{/* Config popover — rendered at page level to escape overflow-hidden */}
|
|
706
|
+
{configPopover && (
|
|
707
|
+
<ConfigPopover
|
|
708
|
+
folder={configPopover.folder}
|
|
709
|
+
anchor={configPopover.anchor}
|
|
710
|
+
onClose={() => setConfigPopover(null)}
|
|
711
|
+
/>
|
|
712
|
+
)}
|
|
713
|
+
|
|
714
|
+
{/* File sidebar */}
|
|
715
|
+
{fileSidebar && (
|
|
716
|
+
<FileSidebar
|
|
717
|
+
title={fileSidebar.title}
|
|
718
|
+
subtitle={fileSidebar.subtitle}
|
|
719
|
+
content={fileSidebar.content}
|
|
720
|
+
loading={fileSidebar.loading}
|
|
721
|
+
onClose={() => setFileSidebar(null)}
|
|
722
|
+
/>
|
|
723
|
+
)}
|
|
724
|
+
</div>
|
|
725
|
+
)
|
|
726
|
+
}
|