@prmichaelsen/acp-visualizer 0.5.0 → 0.5.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prmichaelsen/acp-visualizer",
3
- "version": "0.5.0",
3
+ "version": "0.5.3",
4
4
  "type": "module",
5
5
  "description": "Browser-based dashboard for visualizing ACP progress.yaml data",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "dev": "vite dev --port 3400 --host",
17
17
  "build": "vite build",
18
18
  "serve": "vite preview",
19
- "deploy": "export $(cat .env.cloudflare.local | xargs) && npm run build && wrangler deploy",
19
+ "deploy": "export $(cat .env.cloudflare.local | xargs) && VITE_HOSTED=true npm run build && wrangler deploy",
20
20
  "tail": "export $(cat .env.cloudflare.local | xargs) && wrangler tail",
21
21
  "test": "vitest",
22
22
  "test:run": "vitest run",
@@ -35,7 +35,7 @@ const statusColors: Record<Status, { bg: string; border: string; text: string }>
35
35
 
36
36
  function buildGraph(data: ProgressData): { nodes: GraphNode[]; edges: GraphEdge[]; width: number; height: number } {
37
37
  const g = new dagre.graphlib.Graph()
38
- g.setGraph({ rankdir: 'LR', nodesep: 30, ranksep: 60, marginx: 20, marginy: 20 })
38
+ g.setGraph({ rankdir: 'TB', nodesep: 30, ranksep: 60, marginx: 20, marginy: 20 })
39
39
  g.setDefaultEdgeLabel(() => ({}))
40
40
 
41
41
  // Collect all tasks with their milestone context
@@ -15,6 +15,11 @@ export async function getFileWatcher() {
15
15
  const clients = new Set<Controller>()
16
16
 
17
17
  try {
18
+ // existsSync will throw on Workers where fs is stubbed
19
+ const { existsSync } = await import('fs')
20
+ if (!existsSync(filePath)) {
21
+ throw new Error(`File not found: ${filePath}`)
22
+ }
18
23
  watch(filePath, (eventType) => {
19
24
  if (eventType === 'change') {
20
25
  for (const controller of clients) {
@@ -27,7 +32,7 @@ export async function getFileWatcher() {
27
32
  }
28
33
  })
29
34
  } catch (err) {
30
- console.warn('[FileWatcher] Could not watch file:', err)
35
+ throw new Error(`[FileWatcher] Cannot watch: ${err}`)
31
36
  }
32
37
 
33
38
  watcher = {
@@ -5,6 +5,9 @@ export function useAutoRefresh() {
5
5
  const router = useRouter()
6
6
 
7
7
  useEffect(() => {
8
+ // Skip file-watcher SSE in hosted mode — no local filesystem to watch
9
+ if (import.meta.env.VITE_HOSTED) return
10
+
8
11
  const eventSource = new EventSource('/api/watch')
9
12
 
10
13
  eventSource.onmessage = () => {
@@ -1,5 +1,5 @@
1
- import { HeadContent, Scripts, createRootRoute, Outlet, useRouter } from '@tanstack/react-router'
2
- import { useState, useCallback } from 'react'
1
+ import { HeadContent, Scripts, createRootRoute, Outlet, useRouter, useRouterState } from '@tanstack/react-router'
2
+ import { useState, useCallback, useEffect } from 'react'
3
3
  import { useAutoRefresh } from '../lib/useAutoRefresh'
4
4
  import { Sidebar } from '../components/Sidebar'
5
5
  import { Header } from '../components/Header'
@@ -21,17 +21,20 @@ export const Route = createRootRoute({
21
21
  let progressData: ProgressData | null = null
22
22
  let projects: AcpProject[] = []
23
23
 
24
- try {
25
- const [result, projectList] = await Promise.all([
26
- getProgressData({ data: {} }),
27
- listProjects(),
28
- ])
29
- if (result.ok) {
30
- progressData = result.data
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
31
37
  }
32
- projects = projectList
33
- } catch {
34
- // Cloudflare Workers or other environment without filesystem
35
38
  }
36
39
 
37
40
  return { progressData, projects }
@@ -74,18 +77,59 @@ function AutoRefresh() {
74
77
  return null
75
78
  }
76
79
 
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
+
77
103
  function RootLayout() {
78
104
  const context = Route.useRouteContext()
79
105
  const [progressData, setProgressData] = useState(context.progressData)
80
106
  const [currentProject, setCurrentProject] = useState<string | null>(
81
107
  context.progressData?.project.name || null,
82
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])
83
126
 
84
127
  const handleGitHubLoad = useCallback(async (owner: string, repo: string) => {
85
128
  const result = await fetchGitHubProgress({ data: { owner, repo } })
86
129
  if (result.ok) {
87
130
  setProgressData(result.data)
88
131
  setCurrentProject(`${owner}/${repo}`)
132
+ setRepoInUrl(`${owner}/${repo}`)
89
133
  } else {
90
134
  throw new Error(result.message)
91
135
  }
@@ -99,6 +143,7 @@ function RootLayout() {
99
143
  if (result.ok) {
100
144
  setProgressData(result.data)
101
145
  setCurrentProject(projectId)
146
+ setRepoInUrl(null) // Clear repo param for local projects
102
147
  }
103
148
  }
104
149
  } catch {
@@ -1,28 +1,34 @@
1
1
  import { createFileRoute } from '@tanstack/react-router'
2
- import { getFileWatcher } from '../../lib/file-watcher'
3
2
 
4
3
  export const Route = createFileRoute('/api/watch')({
5
4
  server: {
6
5
  handlers: {
7
6
  GET: async () => {
8
- const watcher = await getFileWatcher()
7
+ // In hosted mode (Cloudflare Workers), there's no filesystem to watch.
8
+ // Return 204 immediately instead of opening a stream that hangs forever.
9
+ try {
10
+ const { getFileWatcher } = await import('../../lib/file-watcher')
11
+ const watcher = await getFileWatcher()
9
12
 
10
- const stream = new ReadableStream({
11
- start(controller) {
12
- watcher.addClient(controller)
13
- },
14
- cancel(controller) {
15
- watcher.removeClient(controller)
16
- },
17
- })
13
+ const stream = new ReadableStream({
14
+ start(controller) {
15
+ watcher.addClient(controller)
16
+ },
17
+ cancel(controller) {
18
+ watcher.removeClient(controller)
19
+ },
20
+ })
18
21
 
19
- return new Response(stream, {
20
- headers: {
21
- 'Content-Type': 'text/event-stream',
22
- 'Cache-Control': 'no-cache',
23
- 'Connection': 'keep-alive',
24
- },
25
- })
22
+ return new Response(stream, {
23
+ headers: {
24
+ 'Content-Type': 'text/event-stream',
25
+ 'Cache-Control': 'no-cache',
26
+ 'Connection': 'keep-alive',
27
+ },
28
+ })
29
+ } catch {
30
+ return new Response(null, { status: 204 })
31
+ }
26
32
  },
27
33
  },
28
34
  },
@@ -12,13 +12,29 @@ function HomePage() {
12
12
  const progressData = useProgressData()
13
13
 
14
14
  if (!progressData) {
15
+ const isHosted = import.meta.env.VITE_HOSTED
16
+
15
17
  return (
16
18
  <div className="p-6">
17
- <div className="p-4 bg-red-900/20 border border-red-800 rounded-lg">
18
- <p className="text-red-400 font-medium">Failed to load progress data</p>
19
- <p className="text-red-400/70 text-sm mt-1">
20
- Check that progress.yaml exists and is valid YAML
21
- </p>
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
+ )}
22
38
  </div>
23
39
  </div>
24
40
  )
@@ -5,47 +5,71 @@ export type GitHubResult =
5
5
  | { ok: true; data: ProgressData }
6
6
  | { ok: false; error: string; message: string }
7
7
 
8
+ async function fetchBranch(
9
+ parseProgressYaml: (raw: string) => ProgressData,
10
+ owner: string,
11
+ repo: string,
12
+ branch: string,
13
+ headers: Record<string, string>,
14
+ ): Promise<GitHubResult> {
15
+ const url = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/agent/progress.yaml`
16
+ try {
17
+ const response = await fetch(url, { headers })
18
+ if (!response.ok) {
19
+ if (response.status === 404) {
20
+ return { ok: false, error: 'NOT_FOUND', message: `No progress.yaml found at ${owner}/${repo} (branch: ${branch})` }
21
+ }
22
+ return { ok: false, error: 'FETCH_ERROR', message: `GitHub returned ${response.status}: ${response.statusText}` }
23
+ }
24
+ const raw = await response.text()
25
+ const data = parseProgressYaml(raw)
26
+ return { ok: true, data }
27
+ } catch (err) {
28
+ return { ok: false, error: 'NETWORK_ERROR', message: err instanceof Error ? err.message : 'Failed to fetch from GitHub' }
29
+ }
30
+ }
31
+
8
32
  export const fetchGitHubProgress = createServerFn({ method: 'GET' })
9
- .validator((input: { owner: string; repo: string; branch?: string; token?: string }) => input)
33
+ .inputValidator((input: { owner: string; repo: string; branch?: string; token?: string }) => input)
10
34
  .handler(async ({ data: input }): Promise<GitHubResult> => {
11
35
  const { parseProgressYaml } = await import('../lib/yaml-loader')
12
36
 
13
- const branch = input.branch || 'main'
14
- const url = `https://raw.githubusercontent.com/${input.owner}/${input.repo}/${branch}/agent/progress.yaml`
37
+ const fetchHeaders: Record<string, string> = { 'Accept': 'text/plain' }
38
+ if (input.token) {
39
+ fetchHeaders['Authorization'] = `token ${input.token}`
40
+ }
41
+
42
+ // If branch specified, use it directly
43
+ if (input.branch) {
44
+ return fetchBranch(parseProgressYaml, input.owner, input.repo, input.branch, fetchHeaders)
45
+ }
15
46
 
47
+ // Try GitHub API for default branch, fall back to trying main → mainline → master
48
+ let detectedBranch: string | null = null
16
49
  try {
17
- const headers: Record<string, string> = {
18
- 'Accept': 'text/plain',
19
- }
20
- if (input.token) {
21
- headers['Authorization'] = `token ${input.token}`
50
+ const metaRes = await fetch(`https://api.github.com/repos/${input.owner}/${input.repo}`, {
51
+ headers: { 'User-Agent': 'acp-visualizer', ...(input.token ? { Authorization: `token ${input.token}` } : {}) },
52
+ })
53
+ if (metaRes.ok) {
54
+ const meta = await metaRes.json() as { default_branch?: string }
55
+ detectedBranch = meta.default_branch || null
22
56
  }
57
+ } catch {
58
+ // Rate limited or network error — fall through to branch probing
59
+ }
23
60
 
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
- }
61
+ const branches = detectedBranch
62
+ ? [detectedBranch]
63
+ : ['main', 'mainline', 'master']
40
64
 
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
- }
65
+ for (const branch of branches) {
66
+ const result = await fetchBranch(parseProgressYaml, input.owner, input.repo, branch, fetchHeaders)
67
+ if (result.ok || result.error !== 'NOT_FOUND') return result
68
+ }
69
+
70
+ return {
71
+ ok: false,
72
+ error: 'NOT_FOUND',
73
+ message: `No progress.yaml found at ${input.owner}/${input.repo} (tried branches: ${branches.join(', ')})`,
50
74
  }
51
75
  })
@@ -6,7 +6,7 @@ export type ProgressResult =
6
6
  | { ok: false; error: 'FILE_NOT_FOUND' | 'PARSE_ERROR' | 'NO_FILESYSTEM'; message: string; path: string }
7
7
 
8
8
  export const getProgressData = createServerFn({ method: 'GET' })
9
- .validator((input: { path?: string }) => input)
9
+ .inputValidator((input: { path?: string }) => input)
10
10
  .handler(async ({ data: input }): Promise<ProgressResult> => {
11
11
  try {
12
12
  const fs = await import('fs')
@@ -44,7 +44,7 @@ export const listProjects = createServerFn({ method: 'GET' }).handler(
44
44
  )
45
45
 
46
46
  export const getProjectProgressPath = createServerFn({ method: 'GET' })
47
- .validator((input: { projectId: string }) => input)
47
+ .inputValidator((input: { projectId: string }) => input)
48
48
  .handler(async ({ data }): Promise<string | null> => {
49
49
  try {
50
50
  const { readFileSync, existsSync } = await import('fs')
package/vite.config.ts CHANGED
@@ -7,10 +7,10 @@ import { cloudflare } from '@cloudflare/vite-plugin'
7
7
 
8
8
  export default defineConfig(({ command }) => ({
9
9
  plugins: [
10
- cloudflare({
11
- viteEnvironment: { name: 'ssr' },
12
- ...(command === 'serve' ? { config: { observability: { enabled: false } } } : {}),
13
- }),
10
+ // Only use Cloudflare plugin for production builds — local dev uses Node.js SSR
11
+ ...(command === 'build'
12
+ ? [cloudflare({ viteEnvironment: { name: 'ssr' } })]
13
+ : []),
14
14
  viteTsConfigPaths({
15
15
  projects: ['./tsconfig.json'],
16
16
  }),