agentlytics 0.2.10 → 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.
@@ -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
+ }