@prmichaelsen/acp-visualizer 0.5.3 → 0.6.0

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.3",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Browser-based dashboard for visualizing ACP progress.yaml data",
6
6
  "bin": {
@@ -29,6 +29,7 @@
29
29
  "dependencies": {
30
30
  "@cloudflare/vite-plugin": "^1.28.0",
31
31
  "@cloudflare/workers-types": "^4.20260313.1",
32
+ "@tailwindcss/typography": "^0.5.19",
32
33
  "@tailwindcss/vite": "^4.0.6",
33
34
  "@tanstack/react-router": "^1.132.0",
34
35
  "@tanstack/react-start": "^1.132.0",
@@ -45,7 +46,9 @@
45
46
  "lucide-react": "^0.544.0",
46
47
  "react": "^19.0.0",
47
48
  "react-dom": "^19.0.0",
49
+ "react-markdown": "^10.1.0",
48
50
  "recharts": "^3.8.0",
51
+ "rehype-highlight": "^7.0.2",
49
52
  "tailwindcss": "^4.0.6",
50
53
  "typescript": "^5.7.2",
51
54
  "vite": "^7.1.7",
@@ -0,0 +1,25 @@
1
+ import { Link } from '@tanstack/react-router'
2
+
3
+ interface BreadcrumbItem {
4
+ label: string
5
+ href?: string
6
+ }
7
+
8
+ export function Breadcrumb({ items }: { items: BreadcrumbItem[] }) {
9
+ return (
10
+ <nav className="flex items-center gap-1.5 text-xs text-gray-500 mb-4">
11
+ {items.map((item, i) => (
12
+ <span key={i} className="flex items-center gap-1.5">
13
+ {i > 0 && <span className="text-gray-700">/</span>}
14
+ {item.href ? (
15
+ <Link to={item.href} className="hover:text-gray-300 transition-colors">
16
+ {item.label}
17
+ </Link>
18
+ ) : (
19
+ <span className="text-gray-400">{item.label}</span>
20
+ )}
21
+ </span>
22
+ ))}
23
+ </nav>
24
+ )
25
+ }
@@ -0,0 +1,28 @@
1
+ import { StatusBadge } from './StatusBadge'
2
+ import type { Status } from '../lib/types'
3
+
4
+ interface DetailField {
5
+ label: string
6
+ value: React.ReactNode
7
+ }
8
+
9
+ interface DetailHeaderProps {
10
+ status: Status
11
+ fields: DetailField[]
12
+ }
13
+
14
+ export function DetailHeader({ status, fields }: DetailHeaderProps) {
15
+ return (
16
+ <div className="bg-gray-900/50 border border-gray-800 rounded-xl p-4 mb-6">
17
+ <div className="flex flex-wrap items-center gap-3">
18
+ <StatusBadge status={status} />
19
+ {fields.map((field, i) => (
20
+ <div key={i} className="flex items-center gap-1.5 text-xs">
21
+ <span className="text-gray-500">{field.label}:</span>
22
+ <span className="text-gray-300">{field.value}</span>
23
+ </div>
24
+ ))}
25
+ </div>
26
+ </div>
27
+ )
28
+ }
@@ -0,0 +1,29 @@
1
+ import ReactMarkdown from 'react-markdown'
2
+ import rehypeHighlight from 'rehype-highlight'
3
+
4
+ interface MarkdownContentProps {
5
+ content: string
6
+ className?: string
7
+ }
8
+
9
+ export function MarkdownContent({ content, className }: MarkdownContentProps) {
10
+ return (
11
+ <ReactMarkdown
12
+ rehypePlugins={[rehypeHighlight]}
13
+ className={`prose prose-invert prose-sm max-w-none
14
+ prose-pre:bg-gray-900 prose-pre:border prose-pre:border-gray-800
15
+ prose-code:bg-gray-800 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-gray-200
16
+ prose-code:before:content-none prose-code:after:content-none
17
+ prose-a:text-blue-400 prose-a:no-underline hover:prose-a:underline
18
+ prose-headings:text-gray-100
19
+ prose-strong:text-gray-200
20
+ prose-th:text-gray-300 prose-th:border-gray-700
21
+ prose-td:border-gray-800
22
+ prose-hr:border-gray-800
23
+ prose-blockquote:border-gray-700 prose-blockquote:text-gray-400
24
+ ${className ?? ''}`}
25
+ >
26
+ {content}
27
+ </ReactMarkdown>
28
+ )
29
+ }
@@ -1,3 +1,4 @@
1
+ import { Link } from '@tanstack/react-router'
1
2
  import { StatusBadge } from './StatusBadge'
2
3
  import { ProgressBar } from './ProgressBar'
3
4
  import { TaskList } from './TaskList'
@@ -29,7 +30,13 @@ function KanbanCard({
29
30
  return (
30
31
  <div className="bg-gray-900/50 border border-gray-800 rounded-lg p-3 hover:border-gray-700 transition-colors">
31
32
  <div className="flex items-start justify-between gap-2 mb-2">
32
- <h4 className="text-sm font-medium leading-tight">{milestone.name}</h4>
33
+ <Link
34
+ to="/milestones/$milestoneId"
35
+ params={{ milestoneId: milestone.id }}
36
+ className="text-sm font-medium leading-tight hover:text-blue-400 transition-colors"
37
+ >
38
+ {milestone.name}
39
+ </Link>
33
40
  </div>
34
41
  <div className="flex items-center gap-2 mb-2">
35
42
  <div className="flex-1">
@@ -1,4 +1,5 @@
1
1
  import { Fragment, useState } from 'react'
2
+ import { Link } from '@tanstack/react-router'
2
3
  import {
3
4
  createColumnHelper,
4
5
  useReactTable,
@@ -52,7 +53,14 @@ export function MilestoneTable({ milestones, tasks }: MilestoneTableProps) {
52
53
  columnHelper.accessor('name', {
53
54
  header: 'Milestone',
54
55
  cell: (info) => (
55
- <span className="text-sm font-medium">{info.getValue()}</span>
56
+ <Link
57
+ to="/milestones/$milestoneId"
58
+ params={{ milestoneId: info.row.original.id }}
59
+ className="text-sm font-medium text-gray-200 hover:text-blue-400 transition-colors"
60
+ onClick={(e) => e.stopPropagation()}
61
+ >
62
+ {info.getValue()}
63
+ </Link>
56
64
  ),
57
65
  }),
58
66
  columnHelper.accessor('status', {
@@ -1,4 +1,5 @@
1
1
  import { useState } from 'react'
2
+ import { Link } from '@tanstack/react-router'
2
3
  import { ChevronDown, ChevronRight } from 'lucide-react'
3
4
  import { StatusBadge } from './StatusBadge'
4
5
  import { ProgressBar } from './ProgressBar'
@@ -35,7 +36,14 @@ function MilestoneTreeRow({
35
36
  ) : (
36
37
  <ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />
37
38
  )}
38
- <span className="flex-1 text-sm font-medium">{milestone.name}</span>
39
+ <Link
40
+ to="/milestones/$milestoneId"
41
+ params={{ milestoneId: milestone.id }}
42
+ className="flex-1 text-sm font-medium hover:text-blue-400 transition-colors"
43
+ onClick={(e) => e.stopPropagation()}
44
+ >
45
+ {milestone.name}
46
+ </Link>
39
47
  <StatusBadge status={milestone.status} />
40
48
  <div className="w-20">
41
49
  <ProgressBar value={milestone.progress} size="sm" />
@@ -1,3 +1,4 @@
1
+ import { Link } from '@tanstack/react-router'
1
2
  import { StatusDot } from './StatusDot'
2
3
  import { ExtraFieldsBadge } from './ExtraFieldsBadge'
3
4
  import type { Task } from '../lib/types'
@@ -16,13 +17,15 @@ export function TaskList({ tasks }: { tasks: Task[] }) {
16
17
  {tasks.map((task) => (
17
18
  <div key={task.id} className="flex items-center gap-2 py-1 text-sm">
18
19
  <StatusDot status={task.status} />
19
- <span
20
- className={
20
+ <Link
21
+ to="/tasks/$taskId"
22
+ params={{ taskId: task.id }}
23
+ className={`hover:text-blue-400 transition-colors ${
21
24
  task.status === 'completed' ? 'text-gray-500' : 'text-gray-200'
22
- }
25
+ }`}
23
26
  >
24
27
  {task.name}
25
- </span>
28
+ </Link>
26
29
  {task.notes && (
27
30
  <span className="text-xs text-gray-600 ml-auto truncate max-w-[200px]">
28
31
  {task.notes}
@@ -14,6 +14,8 @@ import { Route as SearchRouteImport } from './routes/search'
14
14
  import { Route as MilestonesRouteImport } from './routes/milestones'
15
15
  import { Route as ActivityRouteImport } from './routes/activity'
16
16
  import { Route as IndexRouteImport } from './routes/index'
17
+ import { Route as TasksTaskIdRouteImport } from './routes/tasks.$taskId'
18
+ import { Route as MilestonesMilestoneIdRouteImport } from './routes/milestones.$milestoneId'
17
19
  import { Route as ApiWatchRouteImport } from './routes/api/watch'
18
20
 
19
21
  const TasksRoute = TasksRouteImport.update({
@@ -41,6 +43,16 @@ const IndexRoute = IndexRouteImport.update({
41
43
  path: '/',
42
44
  getParentRoute: () => rootRouteImport,
43
45
  } as any)
46
+ const TasksTaskIdRoute = TasksTaskIdRouteImport.update({
47
+ id: '/$taskId',
48
+ path: '/$taskId',
49
+ getParentRoute: () => TasksRoute,
50
+ } as any)
51
+ const MilestonesMilestoneIdRoute = MilestonesMilestoneIdRouteImport.update({
52
+ id: '/$milestoneId',
53
+ path: '/$milestoneId',
54
+ getParentRoute: () => MilestonesRoute,
55
+ } as any)
44
56
  const ApiWatchRoute = ApiWatchRouteImport.update({
45
57
  id: '/api/watch',
46
58
  path: '/api/watch',
@@ -50,27 +62,33 @@ const ApiWatchRoute = ApiWatchRouteImport.update({
50
62
  export interface FileRoutesByFullPath {
51
63
  '/': typeof IndexRoute
52
64
  '/activity': typeof ActivityRoute
53
- '/milestones': typeof MilestonesRoute
65
+ '/milestones': typeof MilestonesRouteWithChildren
54
66
  '/search': typeof SearchRoute
55
- '/tasks': typeof TasksRoute
67
+ '/tasks': typeof TasksRouteWithChildren
56
68
  '/api/watch': typeof ApiWatchRoute
69
+ '/milestones/$milestoneId': typeof MilestonesMilestoneIdRoute
70
+ '/tasks/$taskId': typeof TasksTaskIdRoute
57
71
  }
58
72
  export interface FileRoutesByTo {
59
73
  '/': typeof IndexRoute
60
74
  '/activity': typeof ActivityRoute
61
- '/milestones': typeof MilestonesRoute
75
+ '/milestones': typeof MilestonesRouteWithChildren
62
76
  '/search': typeof SearchRoute
63
- '/tasks': typeof TasksRoute
77
+ '/tasks': typeof TasksRouteWithChildren
64
78
  '/api/watch': typeof ApiWatchRoute
79
+ '/milestones/$milestoneId': typeof MilestonesMilestoneIdRoute
80
+ '/tasks/$taskId': typeof TasksTaskIdRoute
65
81
  }
66
82
  export interface FileRoutesById {
67
83
  __root__: typeof rootRouteImport
68
84
  '/': typeof IndexRoute
69
85
  '/activity': typeof ActivityRoute
70
- '/milestones': typeof MilestonesRoute
86
+ '/milestones': typeof MilestonesRouteWithChildren
71
87
  '/search': typeof SearchRoute
72
- '/tasks': typeof TasksRoute
88
+ '/tasks': typeof TasksRouteWithChildren
73
89
  '/api/watch': typeof ApiWatchRoute
90
+ '/milestones/$milestoneId': typeof MilestonesMilestoneIdRoute
91
+ '/tasks/$taskId': typeof TasksTaskIdRoute
74
92
  }
75
93
  export interface FileRouteTypes {
76
94
  fileRoutesByFullPath: FileRoutesByFullPath
@@ -81,8 +99,18 @@ export interface FileRouteTypes {
81
99
  | '/search'
82
100
  | '/tasks'
83
101
  | '/api/watch'
102
+ | '/milestones/$milestoneId'
103
+ | '/tasks/$taskId'
84
104
  fileRoutesByTo: FileRoutesByTo
85
- to: '/' | '/activity' | '/milestones' | '/search' | '/tasks' | '/api/watch'
105
+ to:
106
+ | '/'
107
+ | '/activity'
108
+ | '/milestones'
109
+ | '/search'
110
+ | '/tasks'
111
+ | '/api/watch'
112
+ | '/milestones/$milestoneId'
113
+ | '/tasks/$taskId'
86
114
  id:
87
115
  | '__root__'
88
116
  | '/'
@@ -91,14 +119,16 @@ export interface FileRouteTypes {
91
119
  | '/search'
92
120
  | '/tasks'
93
121
  | '/api/watch'
122
+ | '/milestones/$milestoneId'
123
+ | '/tasks/$taskId'
94
124
  fileRoutesById: FileRoutesById
95
125
  }
96
126
  export interface RootRouteChildren {
97
127
  IndexRoute: typeof IndexRoute
98
128
  ActivityRoute: typeof ActivityRoute
99
- MilestonesRoute: typeof MilestonesRoute
129
+ MilestonesRoute: typeof MilestonesRouteWithChildren
100
130
  SearchRoute: typeof SearchRoute
101
- TasksRoute: typeof TasksRoute
131
+ TasksRoute: typeof TasksRouteWithChildren
102
132
  ApiWatchRoute: typeof ApiWatchRoute
103
133
  }
104
134
 
@@ -139,6 +169,20 @@ declare module '@tanstack/react-router' {
139
169
  preLoaderRoute: typeof IndexRouteImport
140
170
  parentRoute: typeof rootRouteImport
141
171
  }
172
+ '/tasks/$taskId': {
173
+ id: '/tasks/$taskId'
174
+ path: '/$taskId'
175
+ fullPath: '/tasks/$taskId'
176
+ preLoaderRoute: typeof TasksTaskIdRouteImport
177
+ parentRoute: typeof TasksRoute
178
+ }
179
+ '/milestones/$milestoneId': {
180
+ id: '/milestones/$milestoneId'
181
+ path: '/$milestoneId'
182
+ fullPath: '/milestones/$milestoneId'
183
+ preLoaderRoute: typeof MilestonesMilestoneIdRouteImport
184
+ parentRoute: typeof MilestonesRoute
185
+ }
142
186
  '/api/watch': {
143
187
  id: '/api/watch'
144
188
  path: '/api/watch'
@@ -149,12 +193,34 @@ declare module '@tanstack/react-router' {
149
193
  }
150
194
  }
151
195
 
196
+ interface MilestonesRouteChildren {
197
+ MilestonesMilestoneIdRoute: typeof MilestonesMilestoneIdRoute
198
+ }
199
+
200
+ const MilestonesRouteChildren: MilestonesRouteChildren = {
201
+ MilestonesMilestoneIdRoute: MilestonesMilestoneIdRoute,
202
+ }
203
+
204
+ const MilestonesRouteWithChildren = MilestonesRoute._addFileChildren(
205
+ MilestonesRouteChildren,
206
+ )
207
+
208
+ interface TasksRouteChildren {
209
+ TasksTaskIdRoute: typeof TasksTaskIdRoute
210
+ }
211
+
212
+ const TasksRouteChildren: TasksRouteChildren = {
213
+ TasksTaskIdRoute: TasksTaskIdRoute,
214
+ }
215
+
216
+ const TasksRouteWithChildren = TasksRoute._addFileChildren(TasksRouteChildren)
217
+
152
218
  const rootRouteChildren: RootRouteChildren = {
153
219
  IndexRoute: IndexRoute,
154
220
  ActivityRoute: ActivityRoute,
155
- MilestonesRoute: MilestonesRoute,
221
+ MilestonesRoute: MilestonesRouteWithChildren,
156
222
  SearchRoute: SearchRoute,
157
- TasksRoute: TasksRoute,
223
+ TasksRoute: TasksRouteWithChildren,
158
224
  ApiWatchRoute: ApiWatchRoute,
159
225
  }
160
226
  export const routeTree = rootRouteImport
@@ -0,0 +1,149 @@
1
+ import { createFileRoute, Link } from '@tanstack/react-router'
2
+ import { useState, useEffect } from 'react'
3
+ import { useProgressData } from '../contexts/ProgressContext'
4
+ import { Breadcrumb } from '../components/Breadcrumb'
5
+ import { DetailHeader } from '../components/DetailHeader'
6
+ import { ProgressBar } from '../components/ProgressBar'
7
+ import { StatusDot } from '../components/StatusDot'
8
+ import { MarkdownContent } from '../components/MarkdownContent'
9
+ import { getMarkdownContent, resolveMilestoneFile } from '../services/markdown.service'
10
+ import type { MarkdownResult, ResolveFileResult } from '../services/markdown.service'
11
+
12
+ export const Route = createFileRoute('/milestones/$milestoneId')({
13
+ component: MilestoneDetailPage,
14
+ })
15
+
16
+ /** Read ?repo=owner/repo from URL */
17
+ function getGitHubParams(): { owner: string; repo: string } | undefined {
18
+ if (typeof window === 'undefined') return undefined
19
+ const params = new URLSearchParams(window.location.search)
20
+ const repo = params.get('repo')
21
+ if (!repo) return undefined
22
+ const parts = repo.split('/')
23
+ if (parts.length < 2) return undefined
24
+ return { owner: parts[0], repo: parts[1] }
25
+ }
26
+
27
+ function MilestoneDetailPage() {
28
+ const { milestoneId } = Route.useParams()
29
+ const data = useProgressData()
30
+ const [markdown, setMarkdown] = useState<string | null>(null)
31
+ const [markdownError, setMarkdownError] = useState<string | null>(null)
32
+ const [loading, setLoading] = useState(true)
33
+
34
+ const milestone = data?.milestones.find((m) => m.id === milestoneId)
35
+ const tasks = data?.tasks[milestoneId] || []
36
+
37
+ useEffect(() => {
38
+ if (!milestoneId) return
39
+
40
+ setLoading(true)
41
+ setMarkdown(null)
42
+ setMarkdownError(null)
43
+
44
+ const github = getGitHubParams()
45
+
46
+ resolveMilestoneFile({ data: { milestoneId, github } })
47
+ .then((resolveResult: ResolveFileResult) => {
48
+ if (!resolveResult.ok) {
49
+ setMarkdownError(resolveResult.error)
50
+ setLoading(false)
51
+ return
52
+ }
53
+
54
+ return getMarkdownContent({ data: { filePath: resolveResult.filePath, github } })
55
+ .then((mdResult: MarkdownResult) => {
56
+ if (mdResult.ok) {
57
+ setMarkdown(mdResult.content)
58
+ } else {
59
+ setMarkdownError(mdResult.error)
60
+ }
61
+ })
62
+ })
63
+ .catch((err: Error) => {
64
+ setMarkdownError(err.message)
65
+ })
66
+ .finally(() => {
67
+ setLoading(false)
68
+ })
69
+ }, [milestoneId])
70
+
71
+ if (!data || !milestone) {
72
+ return (
73
+ <div className="p-6">
74
+ <p className="text-gray-500 text-sm">Milestone not found: {milestoneId}</p>
75
+ </div>
76
+ )
77
+ }
78
+
79
+ const fields = [
80
+ ...(milestone.started ? [{ label: 'Started', value: milestone.started }] : []),
81
+ ...(milestone.completed ? [{ label: 'Completed', value: milestone.completed }] : []),
82
+ { label: 'Est', value: `${milestone.estimated_weeks} week${milestone.estimated_weeks === '1' ? '' : 's'}` },
83
+ { label: 'Tasks', value: `${milestone.tasks_completed}/${milestone.tasks_total}` },
84
+ ]
85
+
86
+ return (
87
+ <div className="p-6 max-w-4xl">
88
+ <Breadcrumb
89
+ items={[
90
+ { label: 'Milestones', href: '/milestones' },
91
+ { label: `${milestone.id.replace('milestone_', 'M')} — ${milestone.name}` },
92
+ ]}
93
+ />
94
+
95
+ <h1 className="text-xl font-semibold text-gray-100 mb-3">{milestone.name}</h1>
96
+
97
+ <div className="flex items-center gap-3 mb-4">
98
+ <div className="flex-1 max-w-xs">
99
+ <ProgressBar value={milestone.progress} size="sm" />
100
+ </div>
101
+ <span className="text-xs text-gray-500">{milestone.progress}%</span>
102
+ </div>
103
+
104
+ <DetailHeader status={milestone.status} fields={fields} />
105
+
106
+ {milestone.notes && (
107
+ <p className="text-sm text-gray-400 mb-6">{milestone.notes}</p>
108
+ )}
109
+
110
+ {/* Markdown content */}
111
+ {loading ? (
112
+ <p className="text-sm text-gray-600">Loading document...</p>
113
+ ) : markdown ? (
114
+ <MarkdownContent content={markdown} />
115
+ ) : markdownError ? (
116
+ <div className="bg-gray-900/50 border border-gray-800 rounded-xl p-4 text-sm text-gray-500">
117
+ No document found — {markdownError}
118
+ </div>
119
+ ) : null}
120
+
121
+ {/* Task list */}
122
+ {tasks.length > 0 && (
123
+ <div className="mt-8">
124
+ <h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
125
+ Tasks
126
+ </h2>
127
+ <div className="bg-gray-900/50 border border-gray-800 rounded-xl divide-y divide-gray-800">
128
+ {tasks.map((task) => (
129
+ <Link
130
+ key={task.id}
131
+ to="/tasks/$taskId"
132
+ params={{ taskId: task.id }}
133
+ className="flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-gray-800/50 transition-colors first:rounded-t-xl last:rounded-b-xl"
134
+ >
135
+ <StatusDot status={task.status} />
136
+ <span className={task.status === 'completed' ? 'text-gray-500' : 'text-gray-200'}>
137
+ {task.name}
138
+ </span>
139
+ {task.estimated_hours && (
140
+ <span className="text-xs text-gray-600 ml-auto">{task.estimated_hours}h</span>
141
+ )}
142
+ </Link>
143
+ ))}
144
+ </div>
145
+ </div>
146
+ )}
147
+ </div>
148
+ )
149
+ }
@@ -0,0 +1,169 @@
1
+ import { createFileRoute, Link } from '@tanstack/react-router'
2
+ import { useState, useEffect, useMemo } from 'react'
3
+ import { useProgressData } from '../contexts/ProgressContext'
4
+ import { Breadcrumb } from '../components/Breadcrumb'
5
+ import { DetailHeader } from '../components/DetailHeader'
6
+ import { MarkdownContent } from '../components/MarkdownContent'
7
+ import { getMarkdownContent } from '../services/markdown.service'
8
+ import { resolveTaskFile } from '../services/markdown.service'
9
+ import type { MarkdownResult } from '../services/markdown.service'
10
+
11
+ export const Route = createFileRoute('/tasks/$taskId')({
12
+ component: TaskDetailPage,
13
+ })
14
+
15
+ /** Read ?repo=owner/repo from URL */
16
+ function getGitHubParams(): { owner: string; repo: string } | undefined {
17
+ if (typeof window === 'undefined') return undefined
18
+ const params = new URLSearchParams(window.location.search)
19
+ const repo = params.get('repo')
20
+ if (!repo) return undefined
21
+ const parts = repo.split('/')
22
+ if (parts.length < 2) return undefined
23
+ return { owner: parts[0], repo: parts[1] }
24
+ }
25
+
26
+ function TaskDetailPage() {
27
+ const { taskId } = Route.useParams()
28
+ const data = useProgressData()
29
+ const [markdown, setMarkdown] = useState<string | null>(null)
30
+ const [markdownError, setMarkdownError] = useState<string | null>(null)
31
+ const [loading, setLoading] = useState(true)
32
+
33
+ // Find the task and its parent milestone
34
+ const { task, milestone, siblings } = useMemo(() => {
35
+ if (!data) return { task: null, milestone: null, siblings: { prev: null, next: null } }
36
+
37
+ for (const ms of data.milestones) {
38
+ const msTaskList = data.tasks[ms.id] || []
39
+ const idx = msTaskList.findIndex((t) => t.id === taskId)
40
+ if (idx !== -1) {
41
+ return {
42
+ task: msTaskList[idx],
43
+ milestone: ms,
44
+ siblings: {
45
+ prev: idx > 0 ? msTaskList[idx - 1] : null,
46
+ next: idx < msTaskList.length - 1 ? msTaskList[idx + 1] : null,
47
+ },
48
+ }
49
+ }
50
+ }
51
+ return { task: null, milestone: null, siblings: { prev: null, next: null } }
52
+ }, [data, taskId])
53
+
54
+ useEffect(() => {
55
+ if (!task) return
56
+
57
+ setLoading(true)
58
+ setMarkdown(null)
59
+ setMarkdownError(null)
60
+
61
+ const filePath = resolveTaskFile(task)
62
+ if (!filePath) {
63
+ setMarkdownError('No file path for this task')
64
+ setLoading(false)
65
+ return
66
+ }
67
+
68
+ const github = getGitHubParams()
69
+
70
+ getMarkdownContent({ data: { filePath, github } })
71
+ .then((result: MarkdownResult) => {
72
+ if (result.ok) {
73
+ setMarkdown(result.content)
74
+ } else {
75
+ setMarkdownError(result.error)
76
+ }
77
+ })
78
+ .catch((err: Error) => {
79
+ setMarkdownError(err.message)
80
+ })
81
+ .finally(() => {
82
+ setLoading(false)
83
+ })
84
+ }, [task])
85
+
86
+ if (!data || !task || !milestone) {
87
+ return (
88
+ <div className="p-6">
89
+ <p className="text-gray-500 text-sm">Task not found: {taskId}</p>
90
+ </div>
91
+ )
92
+ }
93
+
94
+ const fields = [
95
+ { label: 'Est', value: `${task.estimated_hours}h` },
96
+ ...(task.completed_date ? [{ label: 'Completed', value: task.completed_date }] : []),
97
+ {
98
+ label: 'Milestone',
99
+ value: (
100
+ <Link
101
+ to="/milestones/$milestoneId"
102
+ params={{ milestoneId: milestone.id }}
103
+ className="text-blue-400 hover:underline"
104
+ >
105
+ {milestone.id.replace('milestone_', 'M')} — {milestone.name}
106
+ </Link>
107
+ ),
108
+ },
109
+ ]
110
+
111
+ return (
112
+ <div className="p-6 max-w-4xl">
113
+ <Breadcrumb
114
+ items={[
115
+ { label: 'Milestones', href: '/milestones' },
116
+ { label: `${milestone.id.replace('milestone_', 'M')} — ${milestone.name}`, href: `/milestones/${milestone.id}` },
117
+ { label: task.name },
118
+ ]}
119
+ />
120
+
121
+ <h1 className="text-xl font-semibold text-gray-100 mb-3">{task.name}</h1>
122
+
123
+ <DetailHeader status={task.status} fields={fields} />
124
+
125
+ {task.notes && (
126
+ <p className="text-sm text-gray-400 mb-6">{task.notes}</p>
127
+ )}
128
+
129
+ {/* Markdown content */}
130
+ {loading ? (
131
+ <p className="text-sm text-gray-600">Loading document...</p>
132
+ ) : markdown ? (
133
+ <MarkdownContent content={markdown} />
134
+ ) : markdownError ? (
135
+ <div className="bg-gray-900/50 border border-gray-800 rounded-xl p-4 text-sm text-gray-500">
136
+ No document found — {markdownError}
137
+ </div>
138
+ ) : null}
139
+
140
+ {/* Prev / Next navigation */}
141
+ {(siblings.prev || siblings.next) && (
142
+ <div className="mt-8 flex items-center justify-between border-t border-gray-800 pt-4">
143
+ {siblings.prev ? (
144
+ <Link
145
+ to="/tasks/$taskId"
146
+ params={{ taskId: siblings.prev.id }}
147
+ className="text-sm text-gray-400 hover:text-gray-200 transition-colors"
148
+ >
149
+ ← {siblings.prev.name}
150
+ </Link>
151
+ ) : (
152
+ <span />
153
+ )}
154
+ {siblings.next ? (
155
+ <Link
156
+ to="/tasks/$taskId"
157
+ params={{ taskId: siblings.next.id }}
158
+ className="text-sm text-gray-400 hover:text-gray-200 transition-colors"
159
+ >
160
+ {siblings.next.name} →
161
+ </Link>
162
+ ) : (
163
+ <span />
164
+ )}
165
+ </div>
166
+ )}
167
+ </div>
168
+ )
169
+ }
@@ -0,0 +1,239 @@
1
+ import { createServerFn } from '@tanstack/react-start'
2
+
3
+ export type MarkdownResult =
4
+ | { ok: true; content: string; filePath: string }
5
+ | { ok: false; content: null; error: string }
6
+
7
+ export type ResolveFileResult =
8
+ | { ok: true; filePath: string }
9
+ | { ok: false; filePath: null; error: string }
10
+
11
+ /**
12
+ * Derives the project base path from PROGRESS_YAML_PATH.
13
+ * e.g. "/home/user/project/agent/progress.yaml" → "/home/user/project"
14
+ */
15
+ function getBasePath(): string {
16
+ const progressPath = process.env.PROGRESS_YAML_PATH || './agent/progress.yaml'
17
+ // Strip "agent/progress.yaml" (or similar trailing segment) to get project root
18
+ return progressPath.replace(/\/agent\/progress\.yaml$/, '') || '.'
19
+ }
20
+
21
+ // ---------- getMarkdownContent ----------
22
+
23
+ export const getMarkdownContent = createServerFn({ method: 'GET' })
24
+ .inputValidator((input: { filePath: string; github?: { owner: string; repo: string; branch?: string; token?: string } }) => input)
25
+ .handler(async ({ data: input }): Promise<MarkdownResult> => {
26
+ // GitHub mode
27
+ if (input.github) {
28
+ return fetchMarkdownFromGitHub(input.filePath, input.github)
29
+ }
30
+
31
+ // Local mode
32
+ return readMarkdownFromDisk(input.filePath)
33
+ })
34
+
35
+ async function readMarkdownFromDisk(relativePath: string): Promise<MarkdownResult> {
36
+ try {
37
+ const fs = await import('fs')
38
+ const path = await import('path')
39
+
40
+ const basePath = getBasePath()
41
+ const fullPath = path.resolve(basePath, relativePath)
42
+
43
+ if (!fs.existsSync(fullPath)) {
44
+ return { ok: false, content: null, error: `File not found: ${relativePath}` }
45
+ }
46
+
47
+ const content = fs.readFileSync(fullPath, 'utf-8')
48
+ return { ok: true, content, filePath: relativePath }
49
+ } catch (err: any) {
50
+ if (err?.code === 'ENOENT') {
51
+ return { ok: false, content: null, error: `File not found: ${relativePath}` }
52
+ }
53
+ if (err?.code === 'EACCES') {
54
+ return { ok: false, content: null, error: `Permission denied: ${relativePath}` }
55
+ }
56
+ return { ok: false, content: null, error: `Failed to read file: ${relativePath}` }
57
+ }
58
+ }
59
+
60
+ async function fetchMarkdownFromGitHub(
61
+ filePath: string,
62
+ github: { owner: string; repo: string; branch?: string; token?: string },
63
+ ): Promise<MarkdownResult> {
64
+ try {
65
+ let branch = github.branch
66
+ if (!branch) {
67
+ try {
68
+ const metaRes = await fetch(`https://api.github.com/repos/${github.owner}/${github.repo}`, {
69
+ headers: github.token ? { Authorization: `token ${github.token}` } : {},
70
+ })
71
+ if (metaRes.ok) {
72
+ const meta = (await metaRes.json()) as { default_branch?: string }
73
+ branch = meta.default_branch || 'main'
74
+ } else {
75
+ branch = 'main'
76
+ }
77
+ } catch {
78
+ branch = 'main'
79
+ }
80
+ }
81
+
82
+ const url = `https://api.github.com/repos/${github.owner}/${github.repo}/contents/${filePath}?ref=${branch}`
83
+ const headers: Record<string, string> = {
84
+ Accept: 'application/vnd.github.v3+json',
85
+ }
86
+ if (github.token) {
87
+ headers['Authorization'] = `token ${github.token}`
88
+ }
89
+
90
+ const response = await fetch(url, { headers })
91
+
92
+ if (!response.ok) {
93
+ if (response.status === 404) {
94
+ return { ok: false, content: null, error: `File not found: ${filePath}` }
95
+ }
96
+ if (response.status === 403) {
97
+ return { ok: false, content: null, error: `GitHub rate limit or permission denied for: ${filePath}` }
98
+ }
99
+ return { ok: false, content: null, error: `GitHub returned ${response.status}: ${response.statusText}` }
100
+ }
101
+
102
+ const json = (await response.json()) as { content?: string; encoding?: string }
103
+ if (!json.content) {
104
+ return { ok: false, content: null, error: `No content returned for: ${filePath}` }
105
+ }
106
+
107
+ const content = Buffer.from(json.content, 'base64').toString('utf-8')
108
+ return { ok: true, content, filePath }
109
+ } catch (err) {
110
+ return {
111
+ ok: false,
112
+ content: null,
113
+ error: err instanceof Error ? err.message : `Failed to fetch from GitHub: ${filePath}`,
114
+ }
115
+ }
116
+ }
117
+
118
+ // ---------- resolveMilestoneFile ----------
119
+
120
+ export const resolveMilestoneFile = createServerFn({ method: 'GET' })
121
+ .inputValidator((input: { milestoneId: string; github?: { owner: string; repo: string; branch?: string; token?: string } }) => input)
122
+ .handler(async ({ data: input }): Promise<ResolveFileResult> => {
123
+ // Extract numeric part: "milestone_1" → "1"
124
+ const match = input.milestoneId.match(/milestone_(\d+)/)
125
+ if (!match) {
126
+ return { ok: false, filePath: null, error: `Invalid milestone id format: ${input.milestoneId}` }
127
+ }
128
+ const num = match[1]
129
+ const dirPath = 'agent/milestones'
130
+
131
+ if (input.github) {
132
+ return resolveMilestoneFromGitHub(num, dirPath, input.github)
133
+ }
134
+
135
+ return resolveMilestoneFromDisk(num, dirPath)
136
+ })
137
+
138
+ async function resolveMilestoneFromDisk(num: string, dirPath: string): Promise<ResolveFileResult> {
139
+ try {
140
+ const fs = await import('fs')
141
+ const path = await import('path')
142
+
143
+ const basePath = getBasePath()
144
+ const fullDir = path.resolve(basePath, dirPath)
145
+
146
+ if (!fs.existsSync(fullDir)) {
147
+ return { ok: false, filePath: null, error: `Milestones directory not found: ${dirPath}` }
148
+ }
149
+
150
+ const files = fs.readdirSync(fullDir)
151
+ const matched = files.find(
152
+ (f: string) => f.startsWith(`milestone-${num}-`) && f.endsWith('.md') && !f.includes('template'),
153
+ )
154
+
155
+ if (!matched) {
156
+ return { ok: false, filePath: null, error: `No milestone file found for milestone_${num}` }
157
+ }
158
+
159
+ return { ok: true, filePath: `${dirPath}/${matched}` }
160
+ } catch (err: any) {
161
+ if (err?.code === 'EACCES') {
162
+ return { ok: false, filePath: null, error: `Permission denied: ${dirPath}` }
163
+ }
164
+ return { ok: false, filePath: null, error: `Failed to scan milestones directory` }
165
+ }
166
+ }
167
+
168
+ async function resolveMilestoneFromGitHub(
169
+ num: string,
170
+ dirPath: string,
171
+ github: { owner: string; repo: string; branch?: string; token?: string },
172
+ ): Promise<ResolveFileResult> {
173
+ try {
174
+ let branch = github.branch
175
+ if (!branch) {
176
+ try {
177
+ const metaRes = await fetch(`https://api.github.com/repos/${github.owner}/${github.repo}`, {
178
+ headers: github.token ? { Authorization: `token ${github.token}` } : {},
179
+ })
180
+ if (metaRes.ok) {
181
+ const meta = (await metaRes.json()) as { default_branch?: string }
182
+ branch = meta.default_branch || 'main'
183
+ } else {
184
+ branch = 'main'
185
+ }
186
+ } catch {
187
+ branch = 'main'
188
+ }
189
+ }
190
+
191
+ const url = `https://api.github.com/repos/${github.owner}/${github.repo}/contents/${dirPath}?ref=${branch}`
192
+ const headers: Record<string, string> = {
193
+ Accept: 'application/vnd.github.v3+json',
194
+ }
195
+ if (github.token) {
196
+ headers['Authorization'] = `token ${github.token}`
197
+ }
198
+
199
+ const response = await fetch(url, { headers })
200
+
201
+ if (!response.ok) {
202
+ if (response.status === 404) {
203
+ return { ok: false, filePath: null, error: `Milestones directory not found on GitHub: ${dirPath}` }
204
+ }
205
+ if (response.status === 403) {
206
+ return { ok: false, filePath: null, error: `GitHub rate limit or permission denied` }
207
+ }
208
+ return { ok: false, filePath: null, error: `GitHub returned ${response.status}: ${response.statusText}` }
209
+ }
210
+
211
+ const entries = (await response.json()) as Array<{ name: string; path: string; type: string }>
212
+ const matched = entries.find(
213
+ (e) => e.name.startsWith(`milestone-${num}-`) && e.name.endsWith('.md') && !e.name.includes('template'),
214
+ )
215
+
216
+ if (!matched) {
217
+ return { ok: false, filePath: null, error: `No milestone file found for milestone_${num}` }
218
+ }
219
+
220
+ return { ok: true, filePath: matched.path }
221
+ } catch (err) {
222
+ return {
223
+ ok: false,
224
+ filePath: null,
225
+ error: err instanceof Error ? err.message : `Failed to list milestones from GitHub`,
226
+ }
227
+ }
228
+ }
229
+
230
+ // ---------- resolveTaskFile ----------
231
+
232
+ /**
233
+ * Resolves the markdown file path for a task.
234
+ * This is a plain function (not a server function) since task data
235
+ * including the `file` field is already available client-side.
236
+ */
237
+ export function resolveTaskFile(task: { file?: string } | null | undefined): string | null {
238
+ return task?.file || null
239
+ }
package/src/styles.css CHANGED
@@ -1,4 +1,6 @@
1
1
  @import "tailwindcss";
2
+ @plugin "@tailwindcss/typography";
3
+ @import "highlight.js/styles/github-dark.css";
2
4
 
3
5
  html {
4
6
  @apply bg-gray-950;