@prmichaelsen/acp-visualizer 0.2.1 → 0.5.2
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/package.json +12 -2
- package/src/components/DependencyGraph.tsx +196 -0
- package/src/components/FilterBar.tsx +1 -0
- package/src/components/GitHubInput.tsx +65 -0
- package/src/components/MilestoneGantt.tsx +150 -0
- package/src/components/MilestoneKanban.tsx +15 -2
- package/src/components/ProjectSelector.tsx +66 -0
- package/src/components/Sidebar.tsx +24 -2
- package/src/components/StatusBadge.tsx +2 -0
- package/src/components/StatusDot.tsx +1 -0
- package/src/components/ViewToggle.tsx +3 -1
- package/src/contexts/ProgressContext.tsx +22 -0
- package/src/lib/types.ts +1 -1
- package/src/lib/yaml-loader-real.spec.ts +54 -1
- package/src/lib/yaml-loader.ts +134 -5
- package/src/routes/__root.tsx +136 -22
- package/src/routes/activity.tsx +2 -2
- package/src/routes/index.tsx +23 -6
- package/src/routes/milestones.tsx +15 -3
- package/src/routes/search.tsx +2 -1
- package/src/routes/tasks.tsx +2 -1
- package/src/server.ts +13 -0
- package/src/services/github.service.ts +51 -0
- package/src/services/progress-database.service.ts +17 -39
- package/src/services/projects.service.ts +69 -0
- package/vite.config.ts +7 -2
package/src/lib/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Status values for milestones and tasks */
|
|
2
|
-
export type Status = 'completed' | 'in_progress' | 'not_started'
|
|
2
|
+
export type Status = 'completed' | 'in_progress' | 'not_started' | 'wont_do'
|
|
3
3
|
|
|
4
4
|
/** Unknown properties from agent-maintained YAML are preserved here */
|
|
5
5
|
export type ExtraFields = Record<string, unknown>
|
|
@@ -37,11 +37,64 @@ describe('parseProgressYaml with real files', () => {
|
|
|
37
37
|
expect(totalTasks).toBeGreaterThan(0)
|
|
38
38
|
|
|
39
39
|
for (const m of result.milestones) {
|
|
40
|
-
expect(['completed', 'in_progress', 'not_started']).toContain(m.status)
|
|
40
|
+
expect(['completed', 'in_progress', 'not_started', 'wont_do']).toContain(m.status)
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
console.log(
|
|
44
44
|
`Parsed: ${result.milestones.length} milestones, ${totalTasks} tasks, ${result.recent_work.length} work entries`,
|
|
45
45
|
)
|
|
46
46
|
})
|
|
47
|
+
|
|
48
|
+
it('parses agentbase.me progress.yaml with inline milestones and standalone tasks', () => {
|
|
49
|
+
const path = '/home/prmichaelsen/.acp/projects/agentbase.me/agent/progress.yaml'
|
|
50
|
+
let raw: string
|
|
51
|
+
try {
|
|
52
|
+
raw = readFileSync(path, 'utf-8')
|
|
53
|
+
} catch {
|
|
54
|
+
console.log('Skipping — agentbase.me progress.yaml not found')
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const result = parseProgressYaml(raw)
|
|
59
|
+
|
|
60
|
+
// Should find project metadata
|
|
61
|
+
expect(result.project.name).toBe('agentbase.me')
|
|
62
|
+
|
|
63
|
+
// Should find inline milestones (milestone_1_firebase_analytics, etc.)
|
|
64
|
+
expect(result.milestones.length).toBeGreaterThan(0)
|
|
65
|
+
const milestoneIds = result.milestones.map((m) => m.id)
|
|
66
|
+
expect(milestoneIds).toContain('M1') // from milestone_1_firebase_analytics
|
|
67
|
+
expect(milestoneIds).toContain('M47') // from milestone_47_agent_memory_system
|
|
68
|
+
|
|
69
|
+
// Should find standard milestones too
|
|
70
|
+
expect(milestoneIds).toContain('M19') // from milestones: array
|
|
71
|
+
expect(milestoneIds).toContain('M21')
|
|
72
|
+
|
|
73
|
+
// Should find tasks from inline milestones
|
|
74
|
+
const m1Tasks = result.tasks['M1'] || []
|
|
75
|
+
expect(m1Tasks.length).toBeGreaterThan(0)
|
|
76
|
+
// Task 79 has wont_do status
|
|
77
|
+
const wontDoTask = m1Tasks.find((t) => String(t.id) === '79')
|
|
78
|
+
if (wontDoTask) {
|
|
79
|
+
expect(wontDoTask.status).toBe('wont_do')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Should find standalone/unassigned tasks
|
|
83
|
+
const unassigned = result.tasks['_unassigned'] || []
|
|
84
|
+
expect(unassigned.length).toBeGreaterThan(0)
|
|
85
|
+
// Synthetic milestone should exist
|
|
86
|
+
expect(milestoneIds).toContain('_unassigned')
|
|
87
|
+
|
|
88
|
+
const totalTasks = Object.values(result.tasks).reduce(
|
|
89
|
+
(sum, ts) => sum + ts.length,
|
|
90
|
+
0,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
// Should collect next_steps from next_immediate_steps too
|
|
94
|
+
expect(result.next_steps.length).toBeGreaterThan(0)
|
|
95
|
+
|
|
96
|
+
console.log(
|
|
97
|
+
`Parsed agentbase.me: ${result.milestones.length} milestones, ${totalTasks} tasks, ${unassigned.length} unassigned`,
|
|
98
|
+
)
|
|
99
|
+
})
|
|
47
100
|
})
|
package/src/lib/yaml-loader.ts
CHANGED
|
@@ -47,6 +47,7 @@ function normalizeStatus(value: unknown): Status {
|
|
|
47
47
|
.replace(/[\s-]/g, '_')
|
|
48
48
|
if (s === 'completed' || s === 'done' || s === 'complete') return 'completed'
|
|
49
49
|
if (s === 'in_progress' || s === 'active' || s === 'wip' || s === 'started') return 'in_progress'
|
|
50
|
+
if (s === 'wont_do' || s === 'won_t_do' || s === 'skipped' || s === 'cancelled' || s === 'canceled') return 'wont_do'
|
|
50
51
|
return 'not_started'
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -56,7 +57,9 @@ function safeString(value: unknown, fallback = ''): string {
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
function safeNumber(value: unknown, fallback = 0): number {
|
|
59
|
-
|
|
60
|
+
// Strip trailing '%' (e.g. "100%" → "100") before parsing
|
|
61
|
+
const cleaned = typeof value === 'string' ? value.replace(/%$/, '') : value
|
|
62
|
+
const n = Number(cleaned)
|
|
60
63
|
return Number.isFinite(n) ? n : fallback
|
|
61
64
|
}
|
|
62
65
|
|
|
@@ -267,6 +270,84 @@ const EMPTY_PROGRESS_DATA: ProgressData = {
|
|
|
267
270
|
progress: { planning: 0, implementation: 0, overall: 0 },
|
|
268
271
|
}
|
|
269
272
|
|
|
273
|
+
// --- Inline milestone extraction ---
|
|
274
|
+
// Some progress.yaml files use top-level keys like `milestone_1_firebase_analytics:`
|
|
275
|
+
// containing milestone metadata + inline `tasks:` arrays.
|
|
276
|
+
|
|
277
|
+
const INLINE_MILESTONE_PATTERN = /^milestone_(\d+)_/
|
|
278
|
+
|
|
279
|
+
function extractInlineMilestones(d: Record<string, unknown>): {
|
|
280
|
+
milestones: Milestone[]
|
|
281
|
+
tasks: Record<string, Task[]>
|
|
282
|
+
} {
|
|
283
|
+
const milestones: Milestone[] = []
|
|
284
|
+
const tasks: Record<string, Task[]> = {}
|
|
285
|
+
|
|
286
|
+
for (const [key, value] of Object.entries(d)) {
|
|
287
|
+
const match = key.match(INLINE_MILESTONE_PATTERN)
|
|
288
|
+
if (!match) continue
|
|
289
|
+
const obj = asRecord(value)
|
|
290
|
+
// Extract milestone ID from the name field (e.g. "M1 - ...") or synthesize from key
|
|
291
|
+
const nameStr = safeString(obj.name)
|
|
292
|
+
const idFromName = nameStr.match(/^(M\d+)\b/)?.[1]
|
|
293
|
+
const milestoneId = idFromName || `M${match[1]}`
|
|
294
|
+
|
|
295
|
+
const milestone = normalizeMilestone(
|
|
296
|
+
{ ...obj, id: milestoneId },
|
|
297
|
+
milestones.length,
|
|
298
|
+
)
|
|
299
|
+
milestones.push(milestone)
|
|
300
|
+
|
|
301
|
+
// Extract inline tasks if present
|
|
302
|
+
if (Array.isArray(obj.tasks)) {
|
|
303
|
+
tasks[milestoneId] = obj.tasks.map((t, i) =>
|
|
304
|
+
normalizeTask(t, milestoneId, i),
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return { milestones, tasks }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// --- Standalone / unassigned task extraction ---
|
|
313
|
+
|
|
314
|
+
function extractLooseTasks(
|
|
315
|
+
d: Record<string, unknown>,
|
|
316
|
+
keys: string[],
|
|
317
|
+
): Task[] {
|
|
318
|
+
const result: Task[] = []
|
|
319
|
+
for (const key of keys) {
|
|
320
|
+
const arr = d[key]
|
|
321
|
+
if (Array.isArray(arr)) {
|
|
322
|
+
result.push(
|
|
323
|
+
...arr.map((t, i) => normalizeTask(t, '_unassigned', result.length + i)),
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return result
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// --- next_steps from multiple sources ---
|
|
331
|
+
|
|
332
|
+
function collectNextSteps(d: Record<string, unknown>): string[] {
|
|
333
|
+
const steps = normalizeStringArray(d.next_steps)
|
|
334
|
+
// next_immediate_steps may be a structured object with sub-keys or an array
|
|
335
|
+
const immediate = d.next_immediate_steps
|
|
336
|
+
if (immediate) {
|
|
337
|
+
if (Array.isArray(immediate)) {
|
|
338
|
+
steps.push(...immediate.map(String))
|
|
339
|
+
} else if (typeof immediate === 'object' && immediate !== null) {
|
|
340
|
+
// Structured object like { ready_to_implement: [...], security: [...] }
|
|
341
|
+
for (const arr of Object.values(immediate)) {
|
|
342
|
+
if (Array.isArray(arr)) {
|
|
343
|
+
steps.push(...arr.map(String))
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return steps
|
|
349
|
+
}
|
|
350
|
+
|
|
270
351
|
export function parseProgressYaml(raw: string): ProgressData {
|
|
271
352
|
try {
|
|
272
353
|
// json: true allows duplicated keys (last wins) — common in agent-maintained YAML
|
|
@@ -276,14 +357,62 @@ export function parseProgressYaml(raw: string): ProgressData {
|
|
|
276
357
|
}
|
|
277
358
|
const d = doc as Record<string, unknown>
|
|
278
359
|
|
|
279
|
-
|
|
360
|
+
// Standard milestones array
|
|
361
|
+
const standardMilestones = normalizeMilestones(d.milestones)
|
|
362
|
+
|
|
363
|
+
// Inline milestone_N_* top-level keys
|
|
364
|
+
const inline = extractInlineMilestones(d)
|
|
365
|
+
|
|
366
|
+
// Merge: inline milestones first (they tend to be older/lower-numbered),
|
|
367
|
+
// then standard milestones. Deduplicate by ID (standard wins).
|
|
368
|
+
const seenIds = new Set<string>()
|
|
369
|
+
const allMilestones: Milestone[] = []
|
|
370
|
+
for (const m of [...inline.milestones, ...standardMilestones]) {
|
|
371
|
+
if (!seenIds.has(m.id)) {
|
|
372
|
+
seenIds.add(m.id)
|
|
373
|
+
allMilestones.push(m)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Standard tasks (from top-level `tasks:` key — last-wins with json: true)
|
|
378
|
+
const standardTasks = normalizeTasks(d.tasks, allMilestones)
|
|
379
|
+
|
|
380
|
+
// Merge inline tasks with standard tasks (standard wins on conflict)
|
|
381
|
+
const allTasks: Record<string, Task[]> = { ...inline.tasks }
|
|
382
|
+
for (const [key, tasks] of Object.entries(standardTasks)) {
|
|
383
|
+
allTasks[key] = tasks // standard overwrites inline for same key
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Standalone and unassigned tasks
|
|
387
|
+
const looseTasks = extractLooseTasks(d, ['standalone_tasks', 'unassigned_tasks'])
|
|
388
|
+
if (looseTasks.length > 0) {
|
|
389
|
+
const existing = allTasks['_unassigned'] || []
|
|
390
|
+
allTasks['_unassigned'] = [...existing, ...looseTasks]
|
|
391
|
+
// Add a synthetic milestone for unassigned tasks if not already present
|
|
392
|
+
if (!seenIds.has('_unassigned')) {
|
|
393
|
+
seenIds.add('_unassigned')
|
|
394
|
+
allMilestones.push({
|
|
395
|
+
id: '_unassigned',
|
|
396
|
+
name: 'Unassigned Tasks',
|
|
397
|
+
status: 'in_progress',
|
|
398
|
+
progress: 0,
|
|
399
|
+
started: null,
|
|
400
|
+
completed: null,
|
|
401
|
+
estimated_weeks: '0',
|
|
402
|
+
tasks_completed: looseTasks.filter((t) => t.status === 'completed').length,
|
|
403
|
+
tasks_total: looseTasks.length,
|
|
404
|
+
notes: 'Tasks not assigned to a specific milestone',
|
|
405
|
+
extra: { synthetic: true },
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
}
|
|
280
409
|
|
|
281
410
|
return {
|
|
282
411
|
project: normalizeProject(d.project),
|
|
283
|
-
milestones,
|
|
284
|
-
tasks:
|
|
412
|
+
milestones: allMilestones,
|
|
413
|
+
tasks: allTasks,
|
|
285
414
|
recent_work: normalizeWorkEntries(d.recent_work),
|
|
286
|
-
next_steps:
|
|
415
|
+
next_steps: collectNextSteps(d),
|
|
287
416
|
notes: normalizeStringArray(d.notes),
|
|
288
417
|
current_blockers: normalizeStringArray(d.current_blockers),
|
|
289
418
|
documentation: normalizeDocStats(d.documentation),
|
package/src/routes/__root.tsx
CHANGED
|
@@ -1,26 +1,43 @@
|
|
|
1
|
-
import { HeadContent, Scripts, createRootRoute, Outlet } from '@tanstack/react-router'
|
|
1
|
+
import { HeadContent, Scripts, createRootRoute, Outlet, useRouter, useRouterState } from '@tanstack/react-router'
|
|
2
|
+
import { useState, useCallback, useEffect } from 'react'
|
|
2
3
|
import { useAutoRefresh } from '../lib/useAutoRefresh'
|
|
3
4
|
import { Sidebar } from '../components/Sidebar'
|
|
4
5
|
import { Header } from '../components/Header'
|
|
5
6
|
import { getProgressData } from '../services/progress-database.service'
|
|
7
|
+
import { listProjects, getProjectProgressPath } from '../services/projects.service'
|
|
8
|
+
import { fetchGitHubProgress } from '../services/github.service'
|
|
6
9
|
import type { ProgressData } from '../lib/types'
|
|
10
|
+
import type { AcpProject } from '../services/projects.service'
|
|
11
|
+
import { ProgressProvider } from '../contexts/ProgressContext'
|
|
7
12
|
|
|
8
13
|
import appCss from '../styles.css?url'
|
|
9
14
|
|
|
10
15
|
export const Route = createRootRoute({
|
|
16
|
+
component: RootLayout,
|
|
17
|
+
notFoundComponent: NotFound,
|
|
18
|
+
shellComponent: RootDocument,
|
|
19
|
+
|
|
11
20
|
beforeLoad: async () => {
|
|
12
21
|
let progressData: ProgressData | null = null
|
|
22
|
+
let projects: AcpProject[] = []
|
|
13
23
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
24
|
+
// VITE_HOSTED mode: skip filesystem access entirely (Cloudflare Workers)
|
|
25
|
+
if (!import.meta.env.VITE_HOSTED) {
|
|
26
|
+
try {
|
|
27
|
+
const [result, projectList] = await Promise.all([
|
|
28
|
+
getProgressData({ data: {} }),
|
|
29
|
+
listProjects(),
|
|
30
|
+
])
|
|
31
|
+
if (result.ok) {
|
|
32
|
+
progressData = result.data
|
|
33
|
+
}
|
|
34
|
+
projects = projectList
|
|
35
|
+
} catch {
|
|
36
|
+
// No filesystem available
|
|
18
37
|
}
|
|
19
|
-
} catch (error) {
|
|
20
|
-
console.error('[Root] Failed to load progress data:', error)
|
|
21
38
|
}
|
|
22
39
|
|
|
23
|
-
return { progressData }
|
|
40
|
+
return { progressData, projects }
|
|
24
41
|
},
|
|
25
42
|
|
|
26
43
|
head: () => ({
|
|
@@ -40,34 +57,131 @@ export const Route = createRootRoute({
|
|
|
40
57
|
{ rel: 'stylesheet', href: appCss },
|
|
41
58
|
],
|
|
42
59
|
}),
|
|
43
|
-
|
|
44
|
-
shellComponent: RootDocument,
|
|
45
60
|
})
|
|
46
61
|
|
|
62
|
+
function NotFound() {
|
|
63
|
+
return (
|
|
64
|
+
<div className="flex items-center justify-center h-full">
|
|
65
|
+
<div className="text-center">
|
|
66
|
+
<h2 className="text-xl font-semibold text-gray-200 mb-2">Page Not Found</h2>
|
|
67
|
+
<p className="text-sm text-gray-400">
|
|
68
|
+
The page you're looking for doesn't exist.
|
|
69
|
+
</p>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
47
75
|
function AutoRefresh() {
|
|
48
76
|
useAutoRefresh()
|
|
49
77
|
return null
|
|
50
78
|
}
|
|
51
79
|
|
|
52
|
-
|
|
53
|
-
|
|
80
|
+
/** Read ?repo=owner/repo from current URL search params */
|
|
81
|
+
function getRepoFromUrl(): { owner: string; repo: string } | null {
|
|
82
|
+
if (typeof window === 'undefined') return null
|
|
83
|
+
const params = new URLSearchParams(window.location.search)
|
|
84
|
+
const repo = params.get('repo')
|
|
85
|
+
if (!repo) return null
|
|
86
|
+
const parts = repo.split('/')
|
|
87
|
+
if (parts.length < 2) return null
|
|
88
|
+
return { owner: parts[0], repo: parts[1] }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Update ?repo= search param without full navigation */
|
|
92
|
+
function setRepoInUrl(ownerRepo: string | null) {
|
|
93
|
+
if (typeof window === 'undefined') return
|
|
94
|
+
const url = new URL(window.location.href)
|
|
95
|
+
if (ownerRepo) {
|
|
96
|
+
url.searchParams.set('repo', ownerRepo)
|
|
97
|
+
} else {
|
|
98
|
+
url.searchParams.delete('repo')
|
|
99
|
+
}
|
|
100
|
+
window.history.replaceState({}, '', url.toString())
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function RootLayout() {
|
|
104
|
+
const context = Route.useRouteContext()
|
|
105
|
+
const [progressData, setProgressData] = useState(context.progressData)
|
|
106
|
+
const [currentProject, setCurrentProject] = useState<string | null>(
|
|
107
|
+
context.progressData?.project.name || null,
|
|
108
|
+
)
|
|
109
|
+
const [initialLoadDone, setInitialLoadDone] = useState(false)
|
|
110
|
+
|
|
111
|
+
// On mount, check for ?repo= param and auto-load
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (initialLoadDone) return
|
|
114
|
+
setInitialLoadDone(true)
|
|
115
|
+
|
|
116
|
+
const repoParam = getRepoFromUrl()
|
|
117
|
+
if (repoParam && !progressData) {
|
|
118
|
+
fetchGitHubProgress({ data: repoParam }).then((result) => {
|
|
119
|
+
if (result.ok) {
|
|
120
|
+
setProgressData(result.data)
|
|
121
|
+
setCurrentProject(`${repoParam.owner}/${repoParam.repo}`)
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
}, [initialLoadDone, progressData])
|
|
126
|
+
|
|
127
|
+
const handleGitHubLoad = useCallback(async (owner: string, repo: string) => {
|
|
128
|
+
const result = await fetchGitHubProgress({ data: { owner, repo } })
|
|
129
|
+
if (result.ok) {
|
|
130
|
+
setProgressData(result.data)
|
|
131
|
+
setCurrentProject(`${owner}/${repo}`)
|
|
132
|
+
setRepoInUrl(`${owner}/${repo}`)
|
|
133
|
+
} else {
|
|
134
|
+
throw new Error(result.message)
|
|
135
|
+
}
|
|
136
|
+
}, [])
|
|
137
|
+
|
|
138
|
+
const handleProjectSwitch = useCallback(async (projectId: string) => {
|
|
139
|
+
try {
|
|
140
|
+
const path = await getProjectProgressPath({ data: { projectId } })
|
|
141
|
+
if (path) {
|
|
142
|
+
const result = await getProgressData({ data: { path } })
|
|
143
|
+
if (result.ok) {
|
|
144
|
+
setProgressData(result.data)
|
|
145
|
+
setCurrentProject(projectId)
|
|
146
|
+
setRepoInUrl(null) // Clear repo param for local projects
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// Project switch failed — likely no filesystem access
|
|
151
|
+
}
|
|
152
|
+
}, [])
|
|
54
153
|
|
|
55
154
|
return (
|
|
56
|
-
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
155
|
+
<>
|
|
156
|
+
<AutoRefresh />
|
|
157
|
+
<div className="flex h-screen bg-gray-950 text-gray-100">
|
|
158
|
+
<Sidebar
|
|
159
|
+
projects={context.projects}
|
|
160
|
+
currentProject={currentProject}
|
|
161
|
+
onProjectSelect={handleProjectSwitch}
|
|
162
|
+
onGitHubLoad={handleGitHubLoad}
|
|
163
|
+
/>
|
|
164
|
+
<ProgressProvider data={progressData}>
|
|
64
165
|
<div className="flex-1 flex flex-col overflow-hidden">
|
|
65
166
|
<Header data={progressData} />
|
|
66
167
|
<main className="flex-1 overflow-auto">
|
|
67
|
-
|
|
168
|
+
<Outlet />
|
|
68
169
|
</main>
|
|
69
170
|
</div>
|
|
70
|
-
</
|
|
171
|
+
</ProgressProvider>
|
|
172
|
+
</div>
|
|
173
|
+
</>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function RootDocument({ children }: { children: React.ReactNode }) {
|
|
178
|
+
return (
|
|
179
|
+
<html lang="en">
|
|
180
|
+
<head>
|
|
181
|
+
<HeadContent />
|
|
182
|
+
</head>
|
|
183
|
+
<body>
|
|
184
|
+
{children}
|
|
71
185
|
<Scripts />
|
|
72
186
|
</body>
|
|
73
187
|
</html>
|
package/src/routes/activity.tsx
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
-
import {
|
|
2
|
+
import { useProgressData } from '../contexts/ProgressContext'
|
|
3
3
|
|
|
4
4
|
export const Route = createFileRoute('/activity')({
|
|
5
5
|
component: ActivityPage,
|
|
6
6
|
})
|
|
7
7
|
|
|
8
8
|
function ActivityPage() {
|
|
9
|
-
const
|
|
9
|
+
const progressData = useProgressData()
|
|
10
10
|
|
|
11
11
|
if (!progressData) {
|
|
12
12
|
return (
|
package/src/routes/index.tsx
CHANGED
|
@@ -2,22 +2,39 @@ import { createFileRoute } from '@tanstack/react-router'
|
|
|
2
2
|
import { StatusBadge } from '../components/StatusBadge'
|
|
3
3
|
import { ProgressBar } from '../components/ProgressBar'
|
|
4
4
|
import { BurndownChart } from '../components/BurndownChart'
|
|
5
|
+
import { useProgressData } from '../contexts/ProgressContext'
|
|
5
6
|
|
|
6
7
|
export const Route = createFileRoute('/')({
|
|
7
8
|
component: HomePage,
|
|
8
9
|
})
|
|
9
10
|
|
|
10
11
|
function HomePage() {
|
|
11
|
-
const
|
|
12
|
+
const progressData = useProgressData()
|
|
12
13
|
|
|
13
14
|
if (!progressData) {
|
|
15
|
+
const isHosted = import.meta.env.VITE_HOSTED
|
|
16
|
+
|
|
14
17
|
return (
|
|
15
18
|
<div className="p-6">
|
|
16
|
-
<div className="p-
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
<div className="p-5 bg-gray-900/50 border border-gray-800 rounded-xl text-center max-w-md mx-auto mt-12">
|
|
20
|
+
<h2 className="text-lg font-semibold mb-2">ACP Progress Visualizer</h2>
|
|
21
|
+
{isHosted ? (
|
|
22
|
+
<>
|
|
23
|
+
<p className="text-gray-400 text-sm mb-4">
|
|
24
|
+
Enter a GitHub repository in the sidebar to visualize its ACP progress.
|
|
25
|
+
</p>
|
|
26
|
+
<div className="text-xs text-gray-600 space-y-1">
|
|
27
|
+
<p>Example: <span className="font-mono text-gray-400">prmichaelsen/agent-context-protocol</span></p>
|
|
28
|
+
<p>The repo must have <span className="font-mono text-gray-400">agent/progress.yaml</span></p>
|
|
29
|
+
</div>
|
|
30
|
+
</>
|
|
31
|
+
) : (
|
|
32
|
+
<>
|
|
33
|
+
<p className="text-red-400/70 text-sm">
|
|
34
|
+
No progress.yaml found. Make sure you're in an ACP project directory.
|
|
35
|
+
</p>
|
|
36
|
+
</>
|
|
37
|
+
)}
|
|
21
38
|
</div>
|
|
22
39
|
</div>
|
|
23
40
|
)
|
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
-
import { useState } from 'react'
|
|
2
|
+
import { useState, lazy, Suspense } from 'react'
|
|
3
3
|
import { MilestoneTable } from '../components/MilestoneTable'
|
|
4
4
|
import { MilestoneTree } from '../components/MilestoneTree'
|
|
5
5
|
import { MilestoneKanban } from '../components/MilestoneKanban'
|
|
6
|
+
import { MilestoneGantt } from '../components/MilestoneGantt'
|
|
6
7
|
import { ViewToggle, type ViewMode } from '../components/ViewToggle'
|
|
7
8
|
import { FilterBar } from '../components/FilterBar'
|
|
8
9
|
import { SearchInput } from '../components/SearchInput'
|
|
9
10
|
import { useFilteredData } from '../lib/useFilteredData'
|
|
11
|
+
import { useProgressData } from '../contexts/ProgressContext'
|
|
10
12
|
import type { Status } from '../lib/types'
|
|
11
13
|
|
|
14
|
+
// Lazy-load DependencyGraph to keep dagre out of the SSR bundle
|
|
15
|
+
// (dagre uses CommonJS require() which fails on Cloudflare Workers)
|
|
16
|
+
const DependencyGraph = lazy(() => import('../components/DependencyGraph').then(m => ({ default: m.DependencyGraph })))
|
|
17
|
+
|
|
12
18
|
export const Route = createFileRoute('/milestones')({
|
|
13
19
|
component: MilestonesPage,
|
|
14
20
|
})
|
|
15
21
|
|
|
16
22
|
function MilestonesPage() {
|
|
17
|
-
const
|
|
23
|
+
const progressData = useProgressData()
|
|
18
24
|
const [view, setView] = useState<ViewMode>('table')
|
|
19
25
|
const [status, setStatus] = useState<Status | 'all'>('all')
|
|
20
26
|
const [search, setSearch] = useState('')
|
|
@@ -47,8 +53,14 @@ function MilestonesPage() {
|
|
|
47
53
|
<MilestoneTable milestones={filtered.milestones} tasks={filtered.tasks} />
|
|
48
54
|
) : view === 'tree' ? (
|
|
49
55
|
<MilestoneTree milestones={filtered.milestones} tasks={filtered.tasks} />
|
|
50
|
-
) : (
|
|
56
|
+
) : view === 'kanban' ? (
|
|
51
57
|
<MilestoneKanban milestones={filtered.milestones} tasks={filtered.tasks} />
|
|
58
|
+
) : view === 'gantt' ? (
|
|
59
|
+
<MilestoneGantt milestones={filtered.milestones} tasks={filtered.tasks} />
|
|
60
|
+
) : (
|
|
61
|
+
<Suspense fallback={<p className="text-gray-500 text-sm">Loading graph...</p>}>
|
|
62
|
+
<DependencyGraph data={filtered} />
|
|
63
|
+
</Suspense>
|
|
52
64
|
)}
|
|
53
65
|
</div>
|
|
54
66
|
)
|
package/src/routes/search.tsx
CHANGED
|
@@ -4,13 +4,14 @@ import { SearchInput } from '../components/SearchInput'
|
|
|
4
4
|
import { StatusBadge } from '../components/StatusBadge'
|
|
5
5
|
import { StatusDot } from '../components/StatusDot'
|
|
6
6
|
import { buildSearchIndex } from '../lib/search'
|
|
7
|
+
import { useProgressData } from '../contexts/ProgressContext'
|
|
7
8
|
|
|
8
9
|
export const Route = createFileRoute('/search')({
|
|
9
10
|
component: SearchPage,
|
|
10
11
|
})
|
|
11
12
|
|
|
12
13
|
function SearchPage() {
|
|
13
|
-
const
|
|
14
|
+
const progressData = useProgressData()
|
|
14
15
|
const [query, setQuery] = useState('')
|
|
15
16
|
|
|
16
17
|
const results = useMemo(() => {
|
package/src/routes/tasks.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createFileRoute } from '@tanstack/react-router'
|
|
2
2
|
import { StatusDot } from '../components/StatusDot'
|
|
3
3
|
import { ExtraFieldsBadge } from '../components/ExtraFieldsBadge'
|
|
4
|
+
import { useProgressData } from '../contexts/ProgressContext'
|
|
4
5
|
import type { Task } from '../lib/types'
|
|
5
6
|
|
|
6
7
|
export const Route = createFileRoute('/tasks')({
|
|
@@ -8,7 +9,7 @@ export const Route = createFileRoute('/tasks')({
|
|
|
8
9
|
})
|
|
9
10
|
|
|
10
11
|
function TasksPage() {
|
|
11
|
-
const
|
|
12
|
+
const progressData = useProgressData()
|
|
12
13
|
|
|
13
14
|
if (!progressData) {
|
|
14
15
|
return (
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Workers Entry Point
|
|
3
|
+
*
|
|
4
|
+
* Minimal server — passes all requests to TanStack Start.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import startServer from '@tanstack/react-start/server-entry'
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
async fetch(request: Request, env: unknown, ctx: unknown) {
|
|
11
|
+
return (startServer as any).fetch(request, env, ctx)
|
|
12
|
+
},
|
|
13
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
2
|
+
import type { ProgressData } from '../lib/types'
|
|
3
|
+
|
|
4
|
+
export type GitHubResult =
|
|
5
|
+
| { ok: true; data: ProgressData }
|
|
6
|
+
| { ok: false; error: string; message: string }
|
|
7
|
+
|
|
8
|
+
export const fetchGitHubProgress = createServerFn({ method: 'GET' })
|
|
9
|
+
.inputValidator((input: { owner: string; repo: string; branch?: string; token?: string }) => input)
|
|
10
|
+
.handler(async ({ data: input }): Promise<GitHubResult> => {
|
|
11
|
+
const { parseProgressYaml } = await import('../lib/yaml-loader')
|
|
12
|
+
|
|
13
|
+
const branch = input.branch || 'main'
|
|
14
|
+
const url = `https://raw.githubusercontent.com/${input.owner}/${input.repo}/${branch}/agent/progress.yaml`
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const headers: Record<string, string> = {
|
|
18
|
+
'Accept': 'text/plain',
|
|
19
|
+
}
|
|
20
|
+
if (input.token) {
|
|
21
|
+
headers['Authorization'] = `token ${input.token}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const response = await fetch(url, { headers })
|
|
25
|
+
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
if (response.status === 404) {
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
error: 'NOT_FOUND',
|
|
31
|
+
message: `No progress.yaml found at ${input.owner}/${input.repo} (branch: ${branch})`,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
error: 'FETCH_ERROR',
|
|
37
|
+
message: `GitHub returned ${response.status}: ${response.statusText}`,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const raw = await response.text()
|
|
42
|
+
const data = parseProgressYaml(raw)
|
|
43
|
+
return { ok: true, data }
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
error: 'NETWORK_ERROR',
|
|
48
|
+
message: err instanceof Error ? err.message : 'Failed to fetch from GitHub',
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
})
|