@prmichaelsen/acp-visualizer 0.13.2 → 0.14.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.13.2",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "description": "Browser-based dashboard for visualizing ACP progress.yaml data",
6
6
  "bin": {
@@ -3,6 +3,7 @@ import { Breadcrumb } from './Breadcrumb'
3
3
  import { MarkdownContent, buildLinkMap } from './MarkdownContent'
4
4
  import { getMarkdownContent, listAgentDirectory } from '../services/markdown.service'
5
5
  import { useProgressData } from '../contexts/ProgressContext'
6
+ import { getGitHubParams } from '../lib/github-auth'
6
7
  import type { MarkdownResult, AgentFile } from '../services/markdown.service'
7
8
 
8
9
  interface DocumentDetailProps {
@@ -12,17 +13,6 @@ interface DocumentDetailProps {
12
13
  sectionHref: string
13
14
  }
14
15
 
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
16
  export function DocumentDetail({ slug, dirPath, sectionLabel, sectionHref }: DocumentDetailProps) {
27
17
  const data = useProgressData()
28
18
  const [markdown, setMarkdown] = useState<string | null>(null)
@@ -2,6 +2,8 @@ import { Link } from '@tanstack/react-router'
2
2
  import { useState, useEffect } from 'react'
3
3
  import { listAgentDirectory } from '../services/markdown.service'
4
4
  import type { AgentFile } from '../services/markdown.service'
5
+ import { getGitHubParams } from '../lib/github-auth'
6
+ import { PreviewButton } from './PreviewButton'
5
7
  import { FileText } from 'lucide-react'
6
8
 
7
9
  interface DocumentListProps {
@@ -11,13 +13,14 @@ interface DocumentListProps {
11
13
  github?: { owner: string; repo: string; branch?: string; token?: string }
12
14
  }
13
15
 
14
- export function DocumentList({ title, dirPath, baseTo, github }: DocumentListProps) {
16
+ export function DocumentList({ title, dirPath, baseTo, github: githubProp }: DocumentListProps) {
15
17
  const [files, setFiles] = useState<AgentFile[]>([])
16
18
  const [error, setError] = useState<string | null>(null)
17
19
  const [loading, setLoading] = useState(true)
18
20
  const [directoryExists, setDirectoryExists] = useState(true)
19
21
 
20
22
  useEffect(() => {
23
+ const github = githubProp ?? getGitHubParams()
21
24
  setLoading(true)
22
25
  setDirectoryExists(true)
23
26
  listAgentDirectory({ data: { dirPath, github } })
@@ -31,7 +34,7 @@ export function DocumentList({ title, dirPath, baseTo, github }: DocumentListPro
31
34
  })
32
35
  .catch((err: Error) => setError(err.message))
33
36
  .finally(() => setLoading(false))
34
- }, [dirPath, github])
37
+ }, [dirPath, githubProp])
35
38
 
36
39
  if (loading) {
37
40
  return (
@@ -57,7 +60,7 @@ export function DocumentList({ title, dirPath, baseTo, github }: DocumentListPro
57
60
  return (
58
61
  <div className="p-6">
59
62
  <h2 className="text-lg font-semibold mb-4">{title}</h2>
60
- {!directoryExists && github ? (
63
+ {!directoryExists && (githubProp || getGitHubParams()) ? (
61
64
  <div className="bg-yellow-900/20 border border-yellow-800/30 rounded-lg p-4">
62
65
  <p className="text-sm text-yellow-300 mb-2">
63
66
  Directory <code className="text-yellow-400 bg-yellow-900/30 px-1.5 py-0.5 rounded">{dirPath}/</code> not found in this repository.
@@ -78,15 +81,20 @@ export function DocumentList({ title, dirPath, baseTo, github }: DocumentListPro
78
81
  <h2 className="text-lg font-semibold mb-4">{title}</h2>
79
82
  <div className="bg-gray-900/50 border border-gray-800 rounded-xl divide-y divide-gray-800">
80
83
  {files.map((file) => (
81
- <Link
84
+ <div
82
85
  key={file.name}
83
- to={baseTo + '/$slug'}
84
- params={{ slug: file.name }}
85
86
  className="flex items-center gap-3 px-4 py-3 text-sm hover:bg-gray-800/50 transition-colors first:rounded-t-xl last:rounded-b-xl"
86
87
  >
87
- <FileText className="w-4 h-4 text-gray-500 shrink-0" />
88
- <span className="text-gray-200">{formatName(file.name)}</span>
89
- </Link>
88
+ <Link
89
+ to={baseTo + '/$slug'}
90
+ params={{ slug: file.name }}
91
+ className="flex items-center gap-3 flex-1 min-w-0"
92
+ >
93
+ <FileText className="w-4 h-4 text-gray-500 shrink-0" />
94
+ <span className="text-gray-200">{formatName(file.name)}</span>
95
+ </Link>
96
+ <PreviewButton type="document" dirPath={dirPath} slug={file.name} />
97
+ </div>
90
98
  ))}
91
99
  </div>
92
100
  <p className="text-xs text-gray-600 mt-3">{files.length} document{files.length !== 1 ? 's' : ''}</p>
@@ -0,0 +1,123 @@
1
+ import { Link } from '@tanstack/react-router'
2
+ import { useState, useEffect, useMemo } from 'react'
3
+ import { Maximize2 } from 'lucide-react'
4
+ import { useProgressData } from '../contexts/ProgressContext'
5
+ import { useSidePanel } from '../contexts/SidePanelContext'
6
+ import { MarkdownContent, buildLinkMap } from './MarkdownContent'
7
+ import { getMarkdownContent, listAgentDirectory } from '../services/markdown.service'
8
+ import { getGitHubParams } from '../lib/github-auth'
9
+ import type { MarkdownResult, AgentFile } from '../services/markdown.service'
10
+
11
+ interface DocumentPreviewProps {
12
+ dirPath: string
13
+ slug: string
14
+ }
15
+
16
+ /** Map dirPath to route base path */
17
+ function dirToRoute(dirPath: string): string {
18
+ if (dirPath.includes('design')) return '/designs'
19
+ if (dirPath.includes('pattern')) return '/patterns'
20
+ if (dirPath.includes('artifact')) return '/artifacts'
21
+ return '/designs'
22
+ }
23
+
24
+ /** Map dirPath to section label */
25
+ function dirToLabel(dirPath: string): string {
26
+ if (dirPath.includes('design')) return 'Design'
27
+ if (dirPath.includes('pattern')) return 'Pattern'
28
+ if (dirPath.includes('artifact')) return 'Artifact'
29
+ return 'Document'
30
+ }
31
+
32
+ /** Turn "local.dashboard-layout-routing" into "Dashboard Layout Routing" */
33
+ function formatName(name: string): string {
34
+ const stripped = name.replace(/^[a-z0-9-]+\./, '')
35
+ return stripped
36
+ .replace(/[-_]/g, ' ')
37
+ .replace(/\b\w/g, (c) => c.toUpperCase())
38
+ }
39
+
40
+ export function DocumentPreview({ dirPath, slug }: DocumentPreviewProps) {
41
+ const data = useProgressData()
42
+ const { close } = useSidePanel()
43
+ const [markdown, setMarkdown] = useState<string | null>(null)
44
+ const [markdownError, setMarkdownError] = useState<string | null>(null)
45
+ const [filePath, setFilePath] = useState<string | null>(null)
46
+ const [loading, setLoading] = useState(true)
47
+ const linkMap = useMemo(() => (data ? buildLinkMap(data) : {}), [data])
48
+
49
+ const routeBase = dirToRoute(dirPath)
50
+ const sectionLabel = dirToLabel(dirPath)
51
+ const displayName = formatName(slug)
52
+
53
+ useEffect(() => {
54
+ setLoading(true)
55
+ setMarkdown(null)
56
+ setMarkdownError(null)
57
+ setFilePath(null)
58
+
59
+ const github = getGitHubParams()
60
+ const relativePath = `${dirPath}/${slug}.md`
61
+
62
+ getMarkdownContent({ data: { filePath: relativePath, github } })
63
+ .then((result: MarkdownResult) => {
64
+ if (result.ok) {
65
+ setMarkdown(result.content)
66
+ setFilePath(result.filePath)
67
+ } else {
68
+ // Fallback: search directory for a file containing the slug
69
+ return listAgentDirectory({ data: { dirPath, github } }).then((listResult) => {
70
+ if (listResult.ok) {
71
+ const match = listResult.files.find((f: AgentFile) => f.name === slug)
72
+ if (match) {
73
+ return getMarkdownContent({ data: { filePath: match.relativePath, github } })
74
+ .then((mdResult: MarkdownResult) => {
75
+ if (mdResult.ok) {
76
+ setMarkdown(mdResult.content)
77
+ setFilePath(mdResult.filePath)
78
+ } else {
79
+ setMarkdownError(mdResult.error)
80
+ }
81
+ })
82
+ }
83
+ }
84
+ setMarkdownError(result.error)
85
+ })
86
+ }
87
+ })
88
+ .catch((err: Error) => setMarkdownError(err.message))
89
+ .finally(() => setLoading(false))
90
+ }, [dirPath, slug])
91
+
92
+ return (
93
+ <div>
94
+ <div className="flex items-start justify-between mb-4">
95
+ <div>
96
+ <p className="text-xs text-gray-500 dark:text-gray-500 uppercase tracking-wider mb-1">{sectionLabel}</p>
97
+ <h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{displayName}</h1>
98
+ </div>
99
+ <Link
100
+ to={routeBase + '/$slug'}
101
+ params={{ slug }}
102
+ className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
103
+ title="Open full view"
104
+ onClick={close}
105
+ >
106
+ <Maximize2 className="w-4 h-4 text-gray-600 dark:text-gray-400" />
107
+ </Link>
108
+ </div>
109
+
110
+ {loading ? (
111
+ <p className="text-sm text-gray-600 dark:text-gray-500">Loading document...</p>
112
+ ) : markdown ? (
113
+ <div className="prose-sm">
114
+ <MarkdownContent content={markdown} basePath={filePath ?? undefined} linkMap={linkMap} />
115
+ </div>
116
+ ) : markdownError ? (
117
+ <div className="bg-gray-100 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800 rounded-xl p-4 text-sm text-gray-600 dark:text-gray-500">
118
+ No document found — {markdownError}
119
+ </div>
120
+ ) : null}
121
+ </div>
122
+ )
123
+ }
@@ -15,12 +15,12 @@ interface FilterBarProps {
15
15
 
16
16
  export function FilterBar({ status, onStatusChange }: FilterBarProps) {
17
17
  return (
18
- <div className="flex flex-wrap gap-2 p-2 bg-gray-800/50 rounded-lg">
18
+ <div className="flex gap-1 p-1 bg-gray-800/50 rounded-lg overflow-x-auto scrollbar-hide">
19
19
  {statusOptions.map((opt) => (
20
20
  <button
21
21
  key={opt.value}
22
22
  onClick={() => onStatusChange(opt.value)}
23
- className={`px-4 py-2 text-sm rounded-md transition-colors whitespace-nowrap min-w-[44px] min-h-[44px] flex items-center justify-center ${
23
+ className={`flex-shrink-0 px-3 py-1.5 text-sm font-medium rounded-md transition-all whitespace-nowrap min-w-[44px] min-h-[44px] flex items-center justify-center ${
24
24
  status === opt.value
25
25
  ? 'bg-gray-700 text-gray-100 shadow-sm'
26
26
  : 'text-gray-500 hover:text-gray-300'
@@ -9,6 +9,7 @@ import { StatusDot } from './StatusDot'
9
9
  import { PriorityBadge } from './PriorityBadge'
10
10
  import { MarkdownContent, buildLinkMap } from './MarkdownContent'
11
11
  import { getMarkdownContent, resolveMilestoneFile } from '../services/markdown.service'
12
+ import { getGitHubParams } from '../lib/github-auth'
12
13
  import { formatMilestoneName } from '../lib/display'
13
14
  import type { MarkdownResult, ResolveFileResult } from '../services/markdown.service'
14
15
 
@@ -16,16 +17,6 @@ interface MilestonePreviewProps {
16
17
  milestoneId: string
17
18
  }
18
19
 
19
- function getGitHubParams(): { owner: string; repo: string } | undefined {
20
- if (typeof window === 'undefined') return undefined
21
- const params = new URLSearchParams(window.location.search)
22
- const repo = params.get('repo')
23
- if (!repo) return undefined
24
- const parts = repo.split('/')
25
- if (parts.length < 2) return undefined
26
- return { owner: parts[0], repo: parts[1] }
27
- }
28
-
29
20
  export function MilestonePreview({ milestoneId }: MilestonePreviewProps) {
30
21
  const data = useProgressData()
31
22
  const { close } = useSidePanel()
@@ -1,31 +1,37 @@
1
1
  import { PanelRight } from 'lucide-react'
2
2
  import { useSidePanel } from '../contexts/SidePanelContext'
3
3
 
4
- interface PreviewButtonProps {
5
- type: 'milestone' | 'task'
6
- id: string
4
+ type PreviewButtonProps = {
7
5
  className?: string
8
- }
6
+ } & (
7
+ | { type: 'milestone' | 'task'; id: string }
8
+ | { type: 'document'; dirPath: string; slug: string }
9
+ )
9
10
 
10
- export function PreviewButton({ type, id, className = '' }: PreviewButtonProps) {
11
- const { openMilestone, openTask } = useSidePanel()
11
+ export function PreviewButton(props: PreviewButtonProps) {
12
+ const { className = '' } = props
13
+ const { openMilestone, openTask, openDocument } = useSidePanel()
12
14
 
13
15
  const handleClick = (e: React.MouseEvent) => {
14
16
  e.preventDefault()
15
17
  e.stopPropagation()
16
- if (type === 'milestone') {
17
- openMilestone(id)
18
+ if (props.type === 'milestone') {
19
+ openMilestone(props.id)
20
+ } else if (props.type === 'task') {
21
+ openTask(props.id)
18
22
  } else {
19
- openTask(id)
23
+ openDocument(props.dirPath, props.slug)
20
24
  }
21
25
  }
22
26
 
27
+ const label = props.type === 'document' ? 'document' : props.type
28
+
23
29
  return (
24
30
  <button
25
31
  onClick={handleClick}
26
32
  className={`p-1.5 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-800 transition-colors ${className}`}
27
- title={`Preview ${type}`}
28
- aria-label={`Preview ${type}`}
33
+ title={`Preview ${label}`}
34
+ aria-label={`Preview ${label}`}
29
35
  >
30
36
  <PanelRight className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
31
37
  </button>
@@ -1,19 +1,38 @@
1
1
  import { Search } from 'lucide-react'
2
+ import { useState, useEffect, useRef } from 'react'
2
3
 
3
4
  interface SearchInputProps {
4
5
  value: string
5
6
  onChange: (value: string) => void
6
7
  placeholder?: string
8
+ debounceMs?: number
7
9
  }
8
10
 
9
- export function SearchInput({ value, onChange, placeholder = 'Search...' }: SearchInputProps) {
11
+ export function SearchInput({ value, onChange, placeholder = 'Search...', debounceMs = 250 }: SearchInputProps) {
12
+ const [localValue, setLocalValue] = useState(value)
13
+ const timerRef = useRef<ReturnType<typeof setTimeout>>(null)
14
+
15
+ // Sync local value when external value changes (e.g. URL navigation)
16
+ useEffect(() => {
17
+ setLocalValue(value)
18
+ }, [value])
19
+
20
+ const handleChange = (next: string) => {
21
+ setLocalValue(next)
22
+ if (timerRef.current) clearTimeout(timerRef.current)
23
+ timerRef.current = setTimeout(() => onChange(next), debounceMs)
24
+ }
25
+
26
+ // Cleanup timer on unmount
27
+ useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current) }, [])
28
+
10
29
  return (
11
30
  <div className="relative">
12
31
  <Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
13
32
  <input
14
33
  type="text"
15
- value={value}
16
- onChange={(e) => onChange(e.target.value)}
34
+ value={localValue}
35
+ onChange={(e) => handleChange(e.target.value)}
17
36
  placeholder={placeholder}
18
37
  className="w-full bg-gray-900 border border-gray-800 rounded-md pl-10 pr-3 py-2 text-base text-gray-200 placeholder-gray-600 focus:outline-none focus:border-gray-600 transition-colors"
19
38
  />
@@ -1,13 +1,30 @@
1
1
  import { X } from 'lucide-react'
2
- import { useRef, useState, useEffect } from 'react'
2
+ import { useRef, useState, useEffect, useCallback } from 'react'
3
3
  import { useSidePanel } from '../contexts/SidePanelContext'
4
4
  import { MilestonePreview } from './MilestonePreview'
5
5
  import { TaskPreview } from './TaskPreview'
6
+ import { DocumentPreview } from './DocumentPreview'
7
+
8
+ function useIsDesktop() {
9
+ const [isDesktop, setIsDesktop] = useState(() =>
10
+ typeof window !== 'undefined' ? window.matchMedia('(min-width: 1024px)').matches : true
11
+ )
12
+
13
+ useEffect(() => {
14
+ const mql = window.matchMedia('(min-width: 1024px)')
15
+ const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches)
16
+ mql.addEventListener('change', handler)
17
+ return () => mql.removeEventListener('change', handler)
18
+ }, [])
19
+
20
+ return isDesktop
21
+ }
6
22
 
7
23
  export function SidePanel() {
8
24
  const { content, isOpen, width, close, setWidth } = useSidePanel()
9
25
  const [isResizing, setIsResizing] = useState(false)
10
26
  const panelRef = useRef<HTMLDivElement>(null)
27
+ const isDesktop = useIsDesktop()
11
28
 
12
29
  // Handle resize drag
13
30
  useEffect(() => {
@@ -44,45 +61,84 @@ export function SidePanel() {
44
61
  }
45
62
  }, [isResizing])
46
63
 
64
+ // Lock body scroll when bottom sheet is open on mobile
65
+ useEffect(() => {
66
+ if (!isOpen || isDesktop) return
67
+ document.body.style.overflow = 'hidden'
68
+ return () => { document.body.style.overflow = '' }
69
+ }, [isOpen, isDesktop])
70
+
47
71
  // Don't render at all until first open
48
72
  if (!content && !isOpen) {
49
73
  return null
50
74
  }
51
75
 
76
+ // Desktop: inline width for resizable side panel
77
+ // Mobile: no inline width, full-width via fixed positioning
78
+ const panelStyle = isDesktop
79
+ ? { width: isOpen ? `${width}px` : '0px' }
80
+ : undefined
81
+
52
82
  return (
53
- <div
54
- ref={panelRef}
55
- className={`relative h-full bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-800 shadow-lg overflow-hidden transition-all duration-300 flex-shrink-0 ${
56
- isOpen ? 'opacity-100' : 'opacity-0 w-0 border-l-0'
57
- }`}
58
- style={{ width: isOpen ? `${width}px` : '0px' }}
59
- >
60
- {/* Resize Handle */}
61
- <div
62
- className={`absolute top-0 left-0 bottom-0 w-1 cursor-ew-resize hover:bg-blue-500 hover:w-1 transition-all z-20 ${
63
- isResizing ? 'bg-blue-500 w-1' : 'bg-transparent'
64
- }`}
65
- onMouseDown={() => setIsResizing(true)}
66
- aria-label="Resize panel"
67
- />
68
-
69
- {/* Header */}
70
- <div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-6 py-4 flex items-center justify-between z-10">
71
- <h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Preview</h2>
72
- <button
83
+ <>
84
+ {/* Mobile backdrop */}
85
+ {isOpen && !isDesktop && (
86
+ <div
87
+ className="fixed inset-0 bg-black/40 backdrop-blur-sm z-40"
73
88
  onClick={close}
74
- className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
75
- aria-label="Close panel"
76
- >
77
- <X className="w-4 h-4 text-gray-600 dark:text-gray-400" />
78
- </button>
79
- </div>
89
+ />
90
+ )}
91
+
92
+ {/* Desktop: side panel | Mobile: bottom sheet */}
93
+ <div
94
+ ref={panelRef}
95
+ className={[
96
+ // Base
97
+ 'bg-white dark:bg-gray-900 shadow-lg overflow-hidden transition-all duration-300 flex-shrink-0',
98
+ // Mobile: bottom sheet
99
+ 'fixed bottom-0 left-0 right-0 z-50 rounded-t-2xl max-h-[85vh] border-t border-gray-200 dark:border-gray-800',
100
+ // Desktop: side panel
101
+ 'lg:relative lg:bottom-auto lg:left-auto lg:right-auto lg:z-auto lg:rounded-none lg:max-h-none lg:h-full lg:border-t-0 lg:border-l',
102
+ // Open/close states
103
+ isOpen
104
+ ? 'opacity-100 translate-y-0'
105
+ : 'opacity-0 translate-y-full lg:translate-y-0 lg:w-0 lg:border-l-0',
106
+ ].join(' ')}
107
+ style={panelStyle}
108
+ >
109
+ {/* Resize Handle — desktop only */}
110
+ <div
111
+ className={`hidden lg:block absolute top-0 left-0 bottom-0 w-1 cursor-ew-resize hover:bg-blue-500 hover:w-1 transition-all z-20 ${
112
+ isResizing ? 'bg-blue-500 w-1' : 'bg-transparent'
113
+ }`}
114
+ onMouseDown={() => setIsResizing(true)}
115
+ aria-label="Resize panel"
116
+ />
117
+
118
+ {/* Drag indicator — mobile only */}
119
+ <div className="lg:hidden flex justify-center pt-2 pb-1">
120
+ <div className="w-10 h-1 rounded-full bg-gray-300 dark:bg-gray-600" />
121
+ </div>
122
+
123
+ {/* Header */}
124
+ <div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-6 py-4 flex items-center justify-between z-10">
125
+ <h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Preview</h2>
126
+ <button
127
+ onClick={close}
128
+ className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
129
+ aria-label="Close panel"
130
+ >
131
+ <X className="w-4 h-4 text-gray-600 dark:text-gray-400" />
132
+ </button>
133
+ </div>
80
134
 
81
- {/* Content */}
82
- <div className="p-6 overflow-auto h-[calc(100%-57px)]">
83
- {content?.type === 'milestone' && <MilestonePreview milestoneId={content.id} />}
84
- {content?.type === 'task' && <TaskPreview taskId={content.id} />}
135
+ {/* Content */}
136
+ <div className="p-6 overflow-auto h-[60vh] lg:h-[calc(100%-57px)]">
137
+ {content?.type === 'milestone' && <MilestonePreview milestoneId={content.id} />}
138
+ {content?.type === 'task' && <TaskPreview taskId={content.id} />}
139
+ {content?.type === 'document' && <DocumentPreview dirPath={content.dirPath} slug={content.slug} />}
140
+ </div>
85
141
  </div>
86
- </div>
142
+ </>
87
143
  )
88
144
  }
@@ -1,5 +1,5 @@
1
1
  import { Link, useRouterState } from '@tanstack/react-router'
2
- import { LayoutDashboard, Flag, CheckSquare, Clock, Search, PenTool, Puzzle, FileBarChart, Github, X, ChevronLeft, ChevronRight } from 'lucide-react'
2
+ import { LayoutDashboard, Flag, CheckSquare, Clock, Search, PenTool, Puzzle, Archive, FileBarChart, Github, X, ChevronLeft, ChevronRight } from 'lucide-react'
3
3
  import { ProjectSelector } from './ProjectSelector'
4
4
  import { GitHubInput } from './GitHubInput'
5
5
  import { GitHubAuth } from './GitHubAuth'
@@ -12,6 +12,7 @@ const navItems = [
12
12
  { to: '/activity' as const, icon: Clock, label: 'Activity' },
13
13
  { to: '/designs' as const, icon: PenTool, label: 'Designs' },
14
14
  { to: '/patterns' as const, icon: Puzzle, label: 'Patterns' },
15
+ { to: '/artifacts' as const, icon: Archive, label: 'Artifacts' },
15
16
  { to: '/reports' as const, icon: FileBarChart, label: 'Reports' },
16
17
  { to: '/github' as const, icon: Github, label: 'GitHub' },
17
18
  ]
@@ -7,6 +7,7 @@ import { DetailHeader } from './DetailHeader'
7
7
  import { PriorityBadge } from './PriorityBadge'
8
8
  import { MarkdownContent, buildLinkMap } from './MarkdownContent'
9
9
  import { getMarkdownContent, resolveTaskFile } from '../services/markdown.service'
10
+ import { getGitHubParams } from '../lib/github-auth'
10
11
  import { formatTaskName, formatMilestoneName } from '../lib/display'
11
12
  import type { MarkdownResult } from '../services/markdown.service'
12
13
 
@@ -14,16 +15,6 @@ interface TaskPreviewProps {
14
15
  taskId: string
15
16
  }
16
17
 
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
18
  export function TaskPreview({ taskId }: TaskPreviewProps) {
28
19
  const data = useProgressData()
29
20
  const { close } = useSidePanel()
@@ -3,6 +3,7 @@ import { createContext, useContext, useState, ReactNode } from 'react'
3
3
  type PanelContent =
4
4
  | { type: 'milestone'; id: string }
5
5
  | { type: 'task'; id: string }
6
+ | { type: 'document'; dirPath: string; slug: string }
6
7
  | null
7
8
 
8
9
  interface SidePanelContextValue {
@@ -11,6 +12,7 @@ interface SidePanelContextValue {
11
12
  width: number
12
13
  openMilestone: (id: string) => void
13
14
  openTask: (id: string) => void
15
+ openDocument: (dirPath: string, slug: string) => void
14
16
  close: () => void
15
17
  setWidth: (width: number) => void
16
18
  }
@@ -46,6 +48,11 @@ export function SidePanelProvider({ children }: { children: ReactNode }) {
46
48
  setIsOpen(true)
47
49
  }
48
50
 
51
+ const openDocument = (dirPath: string, slug: string) => {
52
+ setContent({ type: 'document', dirPath, slug })
53
+ setIsOpen(true)
54
+ }
55
+
49
56
  const close = () => {
50
57
  setIsOpen(false)
51
58
  setTimeout(() => setContent(null), 300) // Wait for animation
@@ -58,7 +65,7 @@ export function SidePanelProvider({ children }: { children: ReactNode }) {
58
65
  }
59
66
 
60
67
  return (
61
- <SidePanelContext.Provider value={{ content, isOpen, width, openMilestone, openTask, close, setWidth }}>
68
+ <SidePanelContext.Provider value={{ content, isOpen, width, openMilestone, openTask, openDocument, close, setWidth }}>
62
69
  {children}
63
70
  </SidePanelContext.Provider>
64
71
  )
@@ -39,6 +39,17 @@ export function setStoredUser(user: GitHubUser) {
39
39
  localStorage.setItem(USER_KEY, JSON.stringify(user))
40
40
  }
41
41
 
42
+ /** Read ?repo=owner/repo from URL search params */
43
+ export function getGitHubParams(): { owner: string; repo: string } | undefined {
44
+ if (typeof window === 'undefined') return undefined
45
+ const params = new URLSearchParams(window.location.search)
46
+ const repo = params.get('repo')
47
+ if (!repo) return undefined
48
+ const parts = repo.split('/')
49
+ if (parts.length < 2) return undefined
50
+ return { owner: parts[0], repo: parts[1] }
51
+ }
52
+
42
53
  export function getGitHubAuthUrl(clientId: string, redirectUri: string): string {
43
54
  const state = crypto.randomUUID()
44
55
  sessionStorage.setItem('github_oauth_state', state)
@@ -16,6 +16,7 @@ import { Route as PatternsRouteImport } from './routes/patterns'
16
16
  import { Route as MilestonesRouteImport } from './routes/milestones'
17
17
  import { Route as GithubRouteImport } from './routes/github'
18
18
  import { Route as DesignsRouteImport } from './routes/designs'
19
+ import { Route as ArtifactsRouteImport } from './routes/artifacts'
19
20
  import { Route as ActivityRouteImport } from './routes/activity'
20
21
  import { Route as IndexRouteImport } from './routes/index'
21
22
  import { Route as TasksIndexRouteImport } from './routes/tasks.index'
@@ -23,11 +24,13 @@ import { Route as ReportsIndexRouteImport } from './routes/reports.index'
23
24
  import { Route as PatternsIndexRouteImport } from './routes/patterns.index'
24
25
  import { Route as MilestonesIndexRouteImport } from './routes/milestones.index'
25
26
  import { Route as DesignsIndexRouteImport } from './routes/designs.index'
27
+ import { Route as ArtifactsIndexRouteImport } from './routes/artifacts.index'
26
28
  import { Route as TasksTaskIdRouteImport } from './routes/tasks.$taskId'
27
29
  import { Route as ReportsSlugRouteImport } from './routes/reports.$slug'
28
30
  import { Route as PatternsSlugRouteImport } from './routes/patterns.$slug'
29
31
  import { Route as MilestonesMilestoneIdRouteImport } from './routes/milestones.$milestoneId'
30
32
  import { Route as DesignsSlugRouteImport } from './routes/designs.$slug'
33
+ import { Route as ArtifactsSlugRouteImport } from './routes/artifacts.$slug'
31
34
  import { Route as ApiWatchRouteImport } from './routes/api/watch'
32
35
  import { Route as AuthGithubCallbackRouteImport } from './routes/auth/github/callback'
33
36
 
@@ -66,6 +69,11 @@ const DesignsRoute = DesignsRouteImport.update({
66
69
  path: '/designs',
67
70
  getParentRoute: () => rootRouteImport,
68
71
  } as any)
72
+ const ArtifactsRoute = ArtifactsRouteImport.update({
73
+ id: '/artifacts',
74
+ path: '/artifacts',
75
+ getParentRoute: () => rootRouteImport,
76
+ } as any)
69
77
  const ActivityRoute = ActivityRouteImport.update({
70
78
  id: '/activity',
71
79
  path: '/activity',
@@ -101,6 +109,11 @@ const DesignsIndexRoute = DesignsIndexRouteImport.update({
101
109
  path: '/',
102
110
  getParentRoute: () => DesignsRoute,
103
111
  } as any)
112
+ const ArtifactsIndexRoute = ArtifactsIndexRouteImport.update({
113
+ id: '/',
114
+ path: '/',
115
+ getParentRoute: () => ArtifactsRoute,
116
+ } as any)
104
117
  const TasksTaskIdRoute = TasksTaskIdRouteImport.update({
105
118
  id: '/$taskId',
106
119
  path: '/$taskId',
@@ -126,6 +139,11 @@ const DesignsSlugRoute = DesignsSlugRouteImport.update({
126
139
  path: '/$slug',
127
140
  getParentRoute: () => DesignsRoute,
128
141
  } as any)
142
+ const ArtifactsSlugRoute = ArtifactsSlugRouteImport.update({
143
+ id: '/$slug',
144
+ path: '/$slug',
145
+ getParentRoute: () => ArtifactsRoute,
146
+ } as any)
129
147
  const ApiWatchRoute = ApiWatchRouteImport.update({
130
148
  id: '/api/watch',
131
149
  path: '/api/watch',
@@ -140,6 +158,7 @@ const AuthGithubCallbackRoute = AuthGithubCallbackRouteImport.update({
140
158
  export interface FileRoutesByFullPath {
141
159
  '/': typeof IndexRoute
142
160
  '/activity': typeof ActivityRoute
161
+ '/artifacts': typeof ArtifactsRouteWithChildren
143
162
  '/designs': typeof DesignsRouteWithChildren
144
163
  '/github': typeof GithubRoute
145
164
  '/milestones': typeof MilestonesRouteWithChildren
@@ -148,11 +167,13 @@ export interface FileRoutesByFullPath {
148
167
  '/search': typeof SearchRoute
149
168
  '/tasks': typeof TasksRouteWithChildren
150
169
  '/api/watch': typeof ApiWatchRoute
170
+ '/artifacts/$slug': typeof ArtifactsSlugRoute
151
171
  '/designs/$slug': typeof DesignsSlugRoute
152
172
  '/milestones/$milestoneId': typeof MilestonesMilestoneIdRoute
153
173
  '/patterns/$slug': typeof PatternsSlugRoute
154
174
  '/reports/$slug': typeof ReportsSlugRoute
155
175
  '/tasks/$taskId': typeof TasksTaskIdRoute
176
+ '/artifacts/': typeof ArtifactsIndexRoute
156
177
  '/designs/': typeof DesignsIndexRoute
157
178
  '/milestones/': typeof MilestonesIndexRoute
158
179
  '/patterns/': typeof PatternsIndexRoute
@@ -166,11 +187,13 @@ export interface FileRoutesByTo {
166
187
  '/github': typeof GithubRoute
167
188
  '/search': typeof SearchRoute
168
189
  '/api/watch': typeof ApiWatchRoute
190
+ '/artifacts/$slug': typeof ArtifactsSlugRoute
169
191
  '/designs/$slug': typeof DesignsSlugRoute
170
192
  '/milestones/$milestoneId': typeof MilestonesMilestoneIdRoute
171
193
  '/patterns/$slug': typeof PatternsSlugRoute
172
194
  '/reports/$slug': typeof ReportsSlugRoute
173
195
  '/tasks/$taskId': typeof TasksTaskIdRoute
196
+ '/artifacts': typeof ArtifactsIndexRoute
174
197
  '/designs': typeof DesignsIndexRoute
175
198
  '/milestones': typeof MilestonesIndexRoute
176
199
  '/patterns': typeof PatternsIndexRoute
@@ -182,6 +205,7 @@ export interface FileRoutesById {
182
205
  __root__: typeof rootRouteImport
183
206
  '/': typeof IndexRoute
184
207
  '/activity': typeof ActivityRoute
208
+ '/artifacts': typeof ArtifactsRouteWithChildren
185
209
  '/designs': typeof DesignsRouteWithChildren
186
210
  '/github': typeof GithubRoute
187
211
  '/milestones': typeof MilestonesRouteWithChildren
@@ -190,11 +214,13 @@ export interface FileRoutesById {
190
214
  '/search': typeof SearchRoute
191
215
  '/tasks': typeof TasksRouteWithChildren
192
216
  '/api/watch': typeof ApiWatchRoute
217
+ '/artifacts/$slug': typeof ArtifactsSlugRoute
193
218
  '/designs/$slug': typeof DesignsSlugRoute
194
219
  '/milestones/$milestoneId': typeof MilestonesMilestoneIdRoute
195
220
  '/patterns/$slug': typeof PatternsSlugRoute
196
221
  '/reports/$slug': typeof ReportsSlugRoute
197
222
  '/tasks/$taskId': typeof TasksTaskIdRoute
223
+ '/artifacts/': typeof ArtifactsIndexRoute
198
224
  '/designs/': typeof DesignsIndexRoute
199
225
  '/milestones/': typeof MilestonesIndexRoute
200
226
  '/patterns/': typeof PatternsIndexRoute
@@ -207,6 +233,7 @@ export interface FileRouteTypes {
207
233
  fullPaths:
208
234
  | '/'
209
235
  | '/activity'
236
+ | '/artifacts'
210
237
  | '/designs'
211
238
  | '/github'
212
239
  | '/milestones'
@@ -215,11 +242,13 @@ export interface FileRouteTypes {
215
242
  | '/search'
216
243
  | '/tasks'
217
244
  | '/api/watch'
245
+ | '/artifacts/$slug'
218
246
  | '/designs/$slug'
219
247
  | '/milestones/$milestoneId'
220
248
  | '/patterns/$slug'
221
249
  | '/reports/$slug'
222
250
  | '/tasks/$taskId'
251
+ | '/artifacts/'
223
252
  | '/designs/'
224
253
  | '/milestones/'
225
254
  | '/patterns/'
@@ -233,11 +262,13 @@ export interface FileRouteTypes {
233
262
  | '/github'
234
263
  | '/search'
235
264
  | '/api/watch'
265
+ | '/artifacts/$slug'
236
266
  | '/designs/$slug'
237
267
  | '/milestones/$milestoneId'
238
268
  | '/patterns/$slug'
239
269
  | '/reports/$slug'
240
270
  | '/tasks/$taskId'
271
+ | '/artifacts'
241
272
  | '/designs'
242
273
  | '/milestones'
243
274
  | '/patterns'
@@ -248,6 +279,7 @@ export interface FileRouteTypes {
248
279
  | '__root__'
249
280
  | '/'
250
281
  | '/activity'
282
+ | '/artifacts'
251
283
  | '/designs'
252
284
  | '/github'
253
285
  | '/milestones'
@@ -256,11 +288,13 @@ export interface FileRouteTypes {
256
288
  | '/search'
257
289
  | '/tasks'
258
290
  | '/api/watch'
291
+ | '/artifacts/$slug'
259
292
  | '/designs/$slug'
260
293
  | '/milestones/$milestoneId'
261
294
  | '/patterns/$slug'
262
295
  | '/reports/$slug'
263
296
  | '/tasks/$taskId'
297
+ | '/artifacts/'
264
298
  | '/designs/'
265
299
  | '/milestones/'
266
300
  | '/patterns/'
@@ -272,6 +306,7 @@ export interface FileRouteTypes {
272
306
  export interface RootRouteChildren {
273
307
  IndexRoute: typeof IndexRoute
274
308
  ActivityRoute: typeof ActivityRoute
309
+ ArtifactsRoute: typeof ArtifactsRouteWithChildren
275
310
  DesignsRoute: typeof DesignsRouteWithChildren
276
311
  GithubRoute: typeof GithubRoute
277
312
  MilestonesRoute: typeof MilestonesRouteWithChildren
@@ -334,6 +369,13 @@ declare module '@tanstack/react-router' {
334
369
  preLoaderRoute: typeof DesignsRouteImport
335
370
  parentRoute: typeof rootRouteImport
336
371
  }
372
+ '/artifacts': {
373
+ id: '/artifacts'
374
+ path: '/artifacts'
375
+ fullPath: '/artifacts'
376
+ preLoaderRoute: typeof ArtifactsRouteImport
377
+ parentRoute: typeof rootRouteImport
378
+ }
337
379
  '/activity': {
338
380
  id: '/activity'
339
381
  path: '/activity'
@@ -383,6 +425,13 @@ declare module '@tanstack/react-router' {
383
425
  preLoaderRoute: typeof DesignsIndexRouteImport
384
426
  parentRoute: typeof DesignsRoute
385
427
  }
428
+ '/artifacts/': {
429
+ id: '/artifacts/'
430
+ path: '/'
431
+ fullPath: '/artifacts/'
432
+ preLoaderRoute: typeof ArtifactsIndexRouteImport
433
+ parentRoute: typeof ArtifactsRoute
434
+ }
386
435
  '/tasks/$taskId': {
387
436
  id: '/tasks/$taskId'
388
437
  path: '/$taskId'
@@ -418,6 +467,13 @@ declare module '@tanstack/react-router' {
418
467
  preLoaderRoute: typeof DesignsSlugRouteImport
419
468
  parentRoute: typeof DesignsRoute
420
469
  }
470
+ '/artifacts/$slug': {
471
+ id: '/artifacts/$slug'
472
+ path: '/$slug'
473
+ fullPath: '/artifacts/$slug'
474
+ preLoaderRoute: typeof ArtifactsSlugRouteImport
475
+ parentRoute: typeof ArtifactsRoute
476
+ }
421
477
  '/api/watch': {
422
478
  id: '/api/watch'
423
479
  path: '/api/watch'
@@ -435,6 +491,20 @@ declare module '@tanstack/react-router' {
435
491
  }
436
492
  }
437
493
 
494
+ interface ArtifactsRouteChildren {
495
+ ArtifactsSlugRoute: typeof ArtifactsSlugRoute
496
+ ArtifactsIndexRoute: typeof ArtifactsIndexRoute
497
+ }
498
+
499
+ const ArtifactsRouteChildren: ArtifactsRouteChildren = {
500
+ ArtifactsSlugRoute: ArtifactsSlugRoute,
501
+ ArtifactsIndexRoute: ArtifactsIndexRoute,
502
+ }
503
+
504
+ const ArtifactsRouteWithChildren = ArtifactsRoute._addFileChildren(
505
+ ArtifactsRouteChildren,
506
+ )
507
+
438
508
  interface DesignsRouteChildren {
439
509
  DesignsSlugRoute: typeof DesignsSlugRoute
440
510
  DesignsIndexRoute: typeof DesignsIndexRoute
@@ -504,6 +574,7 @@ const TasksRouteWithChildren = TasksRoute._addFileChildren(TasksRouteChildren)
504
574
  const rootRouteChildren: RootRouteChildren = {
505
575
  IndexRoute: IndexRoute,
506
576
  ActivityRoute: ActivityRoute,
577
+ ArtifactsRoute: ArtifactsRouteWithChildren,
507
578
  DesignsRoute: DesignsRouteWithChildren,
508
579
  GithubRoute: GithubRoute,
509
580
  MilestonesRoute: MilestonesRouteWithChildren,
@@ -121,12 +121,19 @@ function RootLayout() {
121
121
 
122
122
  const repoParam = getRepoFromUrl()
123
123
  // Repo param takes precedence over default local data
124
- if (repoParam) {
124
+ // In hosted mode with no repo param, default to prmichaelsen/agent-context-protocol
125
+ const target = repoParam
126
+ || (import.meta.env.VITE_HOSTED && !context.progressData
127
+ ? { owner: 'prmichaelsen', repo: 'agent-context-protocol' }
128
+ : null)
129
+
130
+ if (target) {
125
131
  const token = getStoredToken()
126
- void (fetchGitHubProgress({ data: { ...repoParam, token: token || undefined } }) as Promise<GitHubResult>).then((result) => {
132
+ void (fetchGitHubProgress({ data: { ...target, token: token || undefined } }) as Promise<GitHubResult>).then((result) => {
127
133
  if (result.ok) {
128
134
  setProgressData(result.data)
129
- setCurrentProject(`${repoParam.owner}/${repoParam.repo}`)
135
+ setCurrentProject(`${target.owner}/${target.repo}`)
136
+ setRepoInUrl(`${target.owner}/${target.repo}`)
130
137
  }
131
138
  })
132
139
  }
@@ -0,0 +1,18 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { DocumentDetail } from '../components/DocumentDetail'
3
+
4
+ export const Route = createFileRoute('/artifacts/$slug')({
5
+ component: ArtifactDetailPage,
6
+ })
7
+
8
+ function ArtifactDetailPage() {
9
+ const { slug } = Route.useParams()
10
+ return (
11
+ <DocumentDetail
12
+ slug={slug}
13
+ dirPath="agent/artifacts"
14
+ sectionLabel="Artifacts"
15
+ sectionHref="/artifacts"
16
+ />
17
+ )
18
+ }
@@ -0,0 +1,10 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { DocumentList } from '../components/DocumentList'
3
+
4
+ export const Route = createFileRoute('/artifacts/')({
5
+ component: ArtifactsPage,
6
+ })
7
+
8
+ function ArtifactsPage() {
9
+ return <DocumentList title="Artifacts" dirPath="agent/artifacts" baseTo="/artifacts" />
10
+ }
@@ -0,0 +1,9 @@
1
+ import { createFileRoute, Outlet } from '@tanstack/react-router'
2
+
3
+ export const Route = createFileRoute('/artifacts')({
4
+ component: ArtifactsLayout,
5
+ })
6
+
7
+ function ArtifactsLayout() {
8
+ return <Outlet />
9
+ }
@@ -49,39 +49,41 @@ function MilestonesPage() {
49
49
 
50
50
  if (!filtered) {
51
51
  return (
52
- <div className="p-6">
52
+ <div className="py-4 px-4 lg:p-6">
53
53
  <p className="text-gray-600 text-sm">No data loaded</p>
54
54
  </div>
55
55
  )
56
56
  }
57
57
 
58
58
  return (
59
- <div className="p-6">
60
- <div className="flex items-center justify-between mb-4">
59
+ <div className="py-4 lg:p-6">
60
+ <div className="flex items-center justify-between mb-4 px-4 lg:px-0">
61
61
  <h2 className="text-lg font-semibold">Milestones</h2>
62
62
  <ViewToggle value={view} onChange={setView} />
63
63
  </div>
64
64
  {view !== 'kanban' && (
65
- <div className="flex items-center gap-3 mb-4">
65
+ <div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 px-4 lg:px-0">
66
66
  <FilterBar status={status} onStatusChange={setStatus} />
67
- <div className="w-64">
67
+ <div className="w-full sm:w-64">
68
68
  <SearchInput value={search} onChange={setSearch} placeholder="Filter milestones..." />
69
69
  </div>
70
70
  </div>
71
71
  )}
72
- {view === 'table' ? (
73
- <MilestoneTable milestones={filtered.milestones} tasks={filtered.tasks} />
74
- ) : view === 'tree' ? (
75
- <MilestoneTree milestones={filtered.milestones} tasks={filtered.tasks} />
76
- ) : view === 'kanban' ? (
77
- <MilestoneKanban milestones={filtered.milestones} tasks={filtered.tasks} />
78
- ) : view === 'gantt' ? (
79
- <MilestoneGantt milestones={filtered.milestones} tasks={filtered.tasks} />
80
- ) : (
81
- <Suspense fallback={<p className="text-gray-500 text-sm">Loading graph...</p>}>
82
- <DependencyGraph data={filtered} />
83
- </Suspense>
84
- )}
72
+ <div className="overflow-x-auto">
73
+ {view === 'table' ? (
74
+ <MilestoneTable milestones={filtered.milestones} tasks={filtered.tasks} />
75
+ ) : view === 'tree' ? (
76
+ <MilestoneTree milestones={filtered.milestones} tasks={filtered.tasks} />
77
+ ) : view === 'kanban' ? (
78
+ <MilestoneKanban milestones={filtered.milestones} tasks={filtered.tasks} />
79
+ ) : view === 'gantt' ? (
80
+ <MilestoneGantt milestones={filtered.milestones} tasks={filtered.tasks} />
81
+ ) : (
82
+ <Suspense fallback={<p className="text-gray-500 text-sm">Loading graph...</p>}>
83
+ <DependencyGraph data={filtered} />
84
+ </Suspense>
85
+ )}
86
+ </div>
85
87
  </div>
86
88
  )
87
89
  }