@prmichaelsen/acp-visualizer 0.10.3 → 0.12.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.10.3",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "description": "Browser-based dashboard for visualizing ACP progress.yaml data",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  "serve": "vite preview",
19
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
+ "wrangler": "export $(cat .env.cloudflare.local | xargs) && wrangler",
21
22
  "test": "vitest",
22
23
  "test:run": "vitest run",
23
24
  "test:coverage": "vitest run --coverage",
@@ -15,13 +15,16 @@ export function DocumentList({ title, dirPath, baseTo, github }: DocumentListPro
15
15
  const [files, setFiles] = useState<AgentFile[]>([])
16
16
  const [error, setError] = useState<string | null>(null)
17
17
  const [loading, setLoading] = useState(true)
18
+ const [directoryExists, setDirectoryExists] = useState(true)
18
19
 
19
20
  useEffect(() => {
20
21
  setLoading(true)
22
+ setDirectoryExists(true)
21
23
  listAgentDirectory({ data: { dirPath, github } })
22
24
  .then((result) => {
23
25
  if (result.ok) {
24
26
  setFiles(result.files)
27
+ setDirectoryExists(result.directoryExists !== false)
25
28
  } else {
26
29
  setError(result.error)
27
30
  }
@@ -54,7 +57,18 @@ export function DocumentList({ title, dirPath, baseTo, github }: DocumentListPro
54
57
  return (
55
58
  <div className="p-6">
56
59
  <h2 className="text-lg font-semibold mb-4">{title}</h2>
57
- <p className="text-sm text-gray-500">No documents found in <code className="text-gray-400">{dirPath}/</code></p>
60
+ {!directoryExists && github ? (
61
+ <div className="bg-yellow-900/20 border border-yellow-800/30 rounded-lg p-4">
62
+ <p className="text-sm text-yellow-300 mb-2">
63
+ Directory <code className="text-yellow-400 bg-yellow-900/30 px-1.5 py-0.5 rounded">{dirPath}/</code> not found in this repository.
64
+ </p>
65
+ <p className="text-xs text-yellow-400/80">
66
+ This repository may not follow the standard ACP structure. You can create this directory and add .md files to populate this section.
67
+ </p>
68
+ </div>
69
+ ) : (
70
+ <p className="text-sm text-gray-500">No documents found in <code className="text-gray-400">{dirPath}/</code></p>
71
+ )}
58
72
  </div>
59
73
  )
60
74
  }
@@ -0,0 +1,69 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { LogIn, LogOut } from 'lucide-react'
3
+ import { getStoredToken, getStoredUser, clearStoredToken, getGitHubAuthUrl } from '../lib/github-auth'
4
+ import type { GitHubUser } from '../lib/github-auth'
5
+
6
+ export function GitHubAuth() {
7
+ const [token, setToken] = useState<string | null>(null)
8
+ const [user, setUser] = useState<GitHubUser | null>(null)
9
+
10
+ useEffect(() => {
11
+ setToken(getStoredToken())
12
+ setUser(getStoredUser())
13
+ }, [])
14
+
15
+ const handleLogin = () => {
16
+ const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID
17
+ if (!clientId) {
18
+ alert('GitHub OAuth not configured')
19
+ return
20
+ }
21
+
22
+ const redirectUri = `${window.location.origin}/auth/github/callback`
23
+ const authUrl = getGitHubAuthUrl(clientId, redirectUri)
24
+ window.location.href = authUrl
25
+ }
26
+
27
+ const handleLogout = () => {
28
+ clearStoredToken()
29
+ setToken(null)
30
+ setUser(null)
31
+ }
32
+
33
+ if (token && user) {
34
+ return (
35
+ <div className="flex items-center gap-2 p-2 bg-gray-900 border border-gray-800 rounded-md">
36
+ <div className="flex items-center gap-2 flex-1 min-w-0">
37
+ {user.avatar_url && (
38
+ <img
39
+ src={user.avatar_url}
40
+ alt={user.login}
41
+ className="w-6 h-6 rounded-full"
42
+ />
43
+ )}
44
+ <div className="flex-1 min-w-0">
45
+ <p className="text-xs font-medium text-gray-200 truncate">{user.name || user.login}</p>
46
+ <p className="text-xs text-gray-500 truncate">@{user.login}</p>
47
+ </div>
48
+ </div>
49
+ <button
50
+ onClick={handleLogout}
51
+ className="p-1.5 text-gray-400 hover:text-gray-200 hover:bg-gray-800 rounded transition-colors"
52
+ title="Sign out"
53
+ >
54
+ <LogOut className="w-4 h-4" />
55
+ </button>
56
+ </div>
57
+ )
58
+ }
59
+
60
+ return (
61
+ <button
62
+ onClick={handleLogin}
63
+ className="flex items-center justify-center gap-2 w-full px-3 py-2 text-sm text-gray-300 bg-gray-800 border border-gray-700 rounded-md hover:bg-gray-700 transition-colors"
64
+ >
65
+ <LogIn className="w-4 h-4" />
66
+ Sign in with GitHub
67
+ </button>
68
+ )
69
+ }
@@ -1,19 +1,86 @@
1
- import { useState } from 'react'
1
+ import { useState, useEffect, useRef } from 'react'
2
2
  import { Github, Loader2 } from 'lucide-react'
3
+ import { getStoredToken } from '../lib/github-auth'
4
+ import { searchGitHubRepos } from '../services/github-oauth.service'
3
5
 
4
6
  interface GitHubInputProps {
5
7
  onLoad: (owner: string, repo: string) => Promise<void>
6
8
  }
7
9
 
10
+ interface RepoSuggestion {
11
+ full_name: string
12
+ description: string | null
13
+ private: boolean
14
+ }
15
+
8
16
  export function GitHubInput({ onLoad }: GitHubInputProps) {
9
17
  const [value, setValue] = useState('')
10
18
  const [loading, setLoading] = useState(false)
11
19
  const [error, setError] = useState<string | null>(null)
20
+ const [suggestions, setSuggestions] = useState<RepoSuggestion[]>([])
21
+ const [showSuggestions, setShowSuggestions] = useState(false)
22
+ const [searchLoading, setSearchLoading] = useState(false)
23
+ const [dropdownStyle, setDropdownStyle] = useState<{ top: number; left: number; width: number }>({ top: 0, left: 0, width: 0 })
24
+ const inputRef = useRef<HTMLDivElement>(null)
25
+
26
+ const token = getStoredToken()
27
+
28
+ // Update dropdown position when shown
29
+ useEffect(() => {
30
+ if (showSuggestions && inputRef.current) {
31
+ const rect = inputRef.current.getBoundingClientRect()
32
+ setDropdownStyle({
33
+ top: rect.bottom + 4,
34
+ left: rect.left,
35
+ width: rect.width,
36
+ })
37
+ }
38
+ }, [showSuggestions])
39
+
40
+ // Search for repos when user types (authenticated only)
41
+ useEffect(() => {
42
+ if (!token || !value.trim() || value.includes('/')) {
43
+ setSuggestions([])
44
+ return
45
+ }
46
+
47
+ const timeoutId = setTimeout(async () => {
48
+ setSearchLoading(true)
49
+ try {
50
+ const result = await searchGitHubRepos({ data: { token, query: value } })
51
+ if (result.ok) {
52
+ setSuggestions(result.repos)
53
+ setShowSuggestions(true)
54
+ }
55
+ } catch {
56
+ // Silently fail - not critical
57
+ } finally {
58
+ setSearchLoading(false)
59
+ }
60
+ }, 300)
61
+
62
+ return () => clearTimeout(timeoutId)
63
+ }, [value, token])
12
64
 
13
- const handleSubmit = async () => {
65
+ // Close suggestions when clicking outside
66
+ useEffect(() => {
67
+ const handleClickOutside = (event: MouseEvent) => {
68
+ if (inputRef.current && !inputRef.current.contains(event.target as Node)) {
69
+ setShowSuggestions(false)
70
+ }
71
+ }
72
+ document.addEventListener('mousedown', handleClickOutside)
73
+ return () => document.removeEventListener('mousedown', handleClickOutside)
74
+ }, [])
75
+
76
+ const handleSubmit = async (repoFullName?: string) => {
14
77
  setError(null)
78
+ setShowSuggestions(false)
79
+
80
+ const input = repoFullName || value.trim()
81
+
15
82
  // Parse owner/repo from various formats
16
- const cleaned = value.trim()
83
+ const cleaned = input
17
84
  .replace(/^https?:\/\/github\.com\//, '')
18
85
  .replace(/\.git$/, '')
19
86
  .replace(/\/$/, '')
@@ -28,6 +95,7 @@ export function GitHubInput({ onLoad }: GitHubInputProps) {
28
95
  try {
29
96
  await onLoad(parts[0], parts[1])
30
97
  setValue('')
98
+ setSuggestions([])
31
99
  } catch {
32
100
  setError('Failed to load')
33
101
  } finally {
@@ -36,7 +104,7 @@ export function GitHubInput({ onLoad }: GitHubInputProps) {
36
104
  }
37
105
 
38
106
  return (
39
- <div>
107
+ <div ref={inputRef} className="relative">
40
108
  <div className="flex gap-1">
41
109
  <div className="relative flex-1">
42
110
  <Github className="absolute left-2 top-2 w-4 h-4 text-gray-500" />
@@ -44,19 +112,60 @@ export function GitHubInput({ onLoad }: GitHubInputProps) {
44
112
  type="text"
45
113
  value={value}
46
114
  onChange={(e) => { setValue(e.target.value); setError(null) }}
47
- onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
48
- placeholder="owner/repo"
115
+ onKeyDown={(e) => {
116
+ if (e.key === 'Enter') {
117
+ handleSubmit()
118
+ } else if (e.key === 'Escape') {
119
+ setShowSuggestions(false)
120
+ }
121
+ }}
122
+ onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
123
+ placeholder={token ? "Search your repos..." : "owner/repo"}
49
124
  className="w-full bg-gray-900 border border-gray-800 rounded-md pl-8 pr-2 py-2 text-base text-gray-200 placeholder-gray-600 focus:outline-none focus:border-gray-600 transition-colors"
50
125
  />
126
+ {searchLoading && (
127
+ <Loader2 className="absolute right-2 top-2 w-4 h-4 text-gray-500 animate-spin" />
128
+ )}
51
129
  </div>
52
130
  <button
53
- onClick={handleSubmit}
131
+ onClick={() => handleSubmit()}
54
132
  disabled={loading || !value.trim()}
55
133
  className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-md text-sm text-gray-300 hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
56
134
  >
57
135
  {loading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Go'}
58
136
  </button>
59
137
  </div>
138
+
139
+ {/* Suggestions dropdown - using fixed positioning to avoid clipping */}
140
+ {showSuggestions && suggestions.length > 0 && (
141
+ <div
142
+ className="fixed z-[100] bg-gray-900 border border-gray-800 rounded-md shadow-lg max-h-60 overflow-y-auto"
143
+ style={{
144
+ top: `${dropdownStyle.top}px`,
145
+ left: `${dropdownStyle.left}px`,
146
+ width: `${dropdownStyle.width}px`,
147
+ }}
148
+ >
149
+ {suggestions.map((repo) => (
150
+ <button
151
+ key={repo.full_name}
152
+ onClick={() => handleSubmit(repo.full_name)}
153
+ className="w-full text-left px-3 py-2 hover:bg-gray-800 transition-colors border-b border-gray-800 last:border-b-0"
154
+ >
155
+ <div className="flex items-center gap-2">
156
+ <span className="text-sm text-gray-200 font-medium">{repo.full_name}</span>
157
+ {repo.private && (
158
+ <span className="text-xs px-1.5 py-0.5 bg-yellow-900/30 text-yellow-500 rounded">Private</span>
159
+ )}
160
+ </div>
161
+ {repo.description && (
162
+ <p className="text-xs text-gray-500 mt-0.5 truncate">{repo.description}</p>
163
+ )}
164
+ </button>
165
+ ))}
166
+ </div>
167
+ )}
168
+
60
169
  {error && (
61
170
  <p className="text-xs text-red-400 mt-1">{error}</p>
62
171
  )}
@@ -1,10 +1,48 @@
1
1
  import { X } from 'lucide-react'
2
+ import { useRef, useState, useEffect } from 'react'
2
3
  import { useSidePanel } from '../contexts/SidePanelContext'
3
4
  import { MilestonePreview } from './MilestonePreview'
4
5
  import { TaskPreview } from './TaskPreview'
5
6
 
6
7
  export function SidePanel() {
7
- const { content, isOpen, close } = useSidePanel()
8
+ const { content, isOpen, width, close, setWidth } = useSidePanel()
9
+ const [isResizing, setIsResizing] = useState(false)
10
+ const panelRef = useRef<HTMLDivElement>(null)
11
+
12
+ // Handle resize drag
13
+ useEffect(() => {
14
+ if (!isResizing) return
15
+
16
+ const handleMouseMove = (e: MouseEvent) => {
17
+ if (!panelRef.current) return
18
+ const panelRect = panelRef.current.getBoundingClientRect()
19
+ const newWidth = panelRect.right - e.clientX
20
+ setWidth(newWidth)
21
+ }
22
+
23
+ const handleMouseUp = () => {
24
+ setIsResizing(false)
25
+ }
26
+
27
+ document.addEventListener('mousemove', handleMouseMove)
28
+ document.addEventListener('mouseup', handleMouseUp)
29
+
30
+ return () => {
31
+ document.removeEventListener('mousemove', handleMouseMove)
32
+ document.removeEventListener('mouseup', handleMouseUp)
33
+ }
34
+ }, [isResizing, setWidth])
35
+
36
+ // Prevent text selection while resizing
37
+ useEffect(() => {
38
+ if (isResizing) {
39
+ document.body.style.userSelect = 'none'
40
+ document.body.style.cursor = 'ew-resize'
41
+ } else {
42
+ document.body.style.userSelect = ''
43
+ document.body.style.cursor = ''
44
+ }
45
+ }, [isResizing])
8
46
 
9
47
  // Don't render at all until first open
10
48
  if (!content && !isOpen) {
@@ -12,39 +50,39 @@ export function SidePanel() {
12
50
  }
13
51
 
14
52
  return (
15
- <>
16
- {/* Backdrop */}
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 */}
17
61
  <div
18
- className={`fixed inset-0 bg-black/30 backdrop-blur-sm z-40 transition-opacity duration-300 ${
19
- isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
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'
20
64
  }`}
21
- onClick={close}
65
+ onMouseDown={() => setIsResizing(true)}
66
+ aria-label="Resize panel"
22
67
  />
23
68
 
24
- {/* Panel */}
25
- <div
26
- className={`fixed top-0 right-0 h-full w-full lg:max-w-2xl bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-800 shadow-2xl z-50 overflow-auto transition-transform duration-300 ${
27
- isOpen ? 'translate-x-0' : 'translate-x-full'
28
- }`}
29
- >
30
- {/* Header */}
31
- <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">
32
- <h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Preview</h2>
33
- <button
34
- onClick={close}
35
- className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
36
- aria-label="Close panel"
37
- >
38
- <X className="w-4 h-4 text-gray-600 dark:text-gray-400" />
39
- </button>
40
- </div>
41
-
42
- {/* Content */}
43
- <div className="p-6">
44
- {content?.type === 'milestone' && <MilestonePreview milestoneId={content.id} />}
45
- {content?.type === 'task' && <TaskPreview taskId={content.id} />}
46
- </div>
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
73
+ 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>
80
+
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} />}
47
85
  </div>
48
- </>
86
+ </div>
49
87
  )
50
88
  }
@@ -1,7 +1,8 @@
1
1
  import { Link, useRouterState } from '@tanstack/react-router'
2
- import { LayoutDashboard, Flag, CheckSquare, Clock, Search, PenTool, Puzzle, FileBarChart, X } from 'lucide-react'
2
+ import { LayoutDashboard, Flag, CheckSquare, Clock, Search, PenTool, Puzzle, FileBarChart, Github, X } from 'lucide-react'
3
3
  import { ProjectSelector } from './ProjectSelector'
4
4
  import { GitHubInput } from './GitHubInput'
5
+ import { GitHubAuth } from './GitHubAuth'
5
6
  import type { AcpProject } from '../services/projects.service'
6
7
 
7
8
  const navItems = [
@@ -12,6 +13,7 @@ const navItems = [
12
13
  { to: '/designs' as const, icon: PenTool, label: 'Designs' },
13
14
  { to: '/patterns' as const, icon: Puzzle, label: 'Patterns' },
14
15
  { to: '/reports' as const, icon: FileBarChart, label: 'Reports' },
16
+ { to: '/github' as const, icon: Github, label: 'GitHub' },
15
17
  ]
16
18
 
17
19
  interface SidebarProps {
@@ -26,7 +28,7 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
26
28
  const location = useRouterState({ select: (s) => s.location })
27
29
 
28
30
  return (
29
- <nav className="w-full lg:w-56 h-auto max-h-[80vh] lg:h-full border-t lg:border-t-0 lg:border-r border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-950 flex flex-col shrink-0 rounded-t-2xl lg:rounded-none overflow-hidden">
31
+ <nav className="w-full lg:w-56 h-auto max-h-[80vh] lg:h-full border-t lg:border-t-0 lg:border-r border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-950 flex flex-col shrink-0 rounded-t-2xl lg:rounded-none overflow-y-auto">
30
32
  <div className="p-4 border-b border-gray-200 dark:border-gray-800 flex items-center justify-between">
31
33
  <span className="text-sm font-semibold text-gray-700 dark:text-gray-300 tracking-wide">
32
34
  ACP Visualizer
@@ -50,7 +52,7 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
50
52
  />
51
53
  </div>
52
54
  )}
53
- <div className="flex-1 py-2 overflow-y-auto">
55
+ <div className="py-2">
54
56
  {navItems.map((item) => {
55
57
  const isActive =
56
58
  item.to === '/'
@@ -74,7 +76,8 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
74
76
  )
75
77
  })}
76
78
  </div>
77
- <div className="p-3 border-t border-gray-200 dark:border-gray-800 space-y-2">
79
+ <hr className="border-gray-200 dark:border-gray-800" />
80
+ <div className="p-3 space-y-2">
78
81
  <Link
79
82
  to="/search"
80
83
  className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-500 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-800 rounded-md hover:text-gray-900 dark:hover:text-gray-300 hover:border-gray-400 dark:hover:border-gray-600 transition-colors"
@@ -83,6 +86,7 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
83
86
  <Search className="w-4 h-4" />
84
87
  Search...
85
88
  </Link>
89
+ <GitHubAuth />
86
90
  {onGitHubLoad && (
87
91
  <GitHubInput onLoad={onGitHubLoad} />
88
92
  )}
@@ -8,16 +8,23 @@ type PanelContent =
8
8
  interface SidePanelContextValue {
9
9
  content: PanelContent
10
10
  isOpen: boolean
11
+ width: number
11
12
  openMilestone: (id: string) => void
12
13
  openTask: (id: string) => void
13
14
  close: () => void
15
+ setWidth: (width: number) => void
14
16
  }
15
17
 
16
18
  const SidePanelContext = createContext<SidePanelContextValue | undefined>(undefined)
17
19
 
20
+ const MIN_WIDTH = 300
21
+ const MAX_WIDTH = 800
22
+ const DEFAULT_WIDTH = 500
23
+
18
24
  export function SidePanelProvider({ children }: { children: ReactNode }) {
19
25
  const [content, setContent] = useState<PanelContent>(null)
20
26
  const [isOpen, setIsOpen] = useState(false)
27
+ const [width, setWidthState] = useState(DEFAULT_WIDTH)
21
28
 
22
29
  const openMilestone = (id: string) => {
23
30
  setContent({ type: 'milestone', id })
@@ -34,8 +41,13 @@ export function SidePanelProvider({ children }: { children: ReactNode }) {
34
41
  setTimeout(() => setContent(null), 300) // Wait for animation
35
42
  }
36
43
 
44
+ const setWidth = (newWidth: number) => {
45
+ const clampedWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, newWidth))
46
+ setWidthState(clampedWidth)
47
+ }
48
+
37
49
  return (
38
- <SidePanelContext.Provider value={{ content, isOpen, openMilestone, openTask, close }}>
50
+ <SidePanelContext.Provider value={{ content, isOpen, width, openMilestone, openTask, close, setWidth }}>
39
51
  {children}
40
52
  </SidePanelContext.Provider>
41
53
  )
@@ -0,0 +1,60 @@
1
+ const TOKEN_KEY = 'github_access_token'
2
+ const USER_KEY = 'github_user'
3
+
4
+ export interface GitHubUser {
5
+ login: string
6
+ name: string | null
7
+ avatar_url: string
8
+ }
9
+
10
+ export function getStoredToken(): string | null {
11
+ if (typeof window === 'undefined') return null
12
+ return localStorage.getItem(TOKEN_KEY)
13
+ }
14
+
15
+ export function setStoredToken(token: string) {
16
+ if (typeof window === 'undefined') return
17
+ localStorage.setItem(TOKEN_KEY, token)
18
+ }
19
+
20
+ export function clearStoredToken() {
21
+ if (typeof window === 'undefined') return
22
+ localStorage.removeItem(TOKEN_KEY)
23
+ localStorage.removeItem(USER_KEY)
24
+ }
25
+
26
+ export function getStoredUser(): GitHubUser | null {
27
+ if (typeof window === 'undefined') return null
28
+ const stored = localStorage.getItem(USER_KEY)
29
+ if (!stored) return null
30
+ try {
31
+ return JSON.parse(stored)
32
+ } catch {
33
+ return null
34
+ }
35
+ }
36
+
37
+ export function setStoredUser(user: GitHubUser) {
38
+ if (typeof window === 'undefined') return
39
+ localStorage.setItem(USER_KEY, JSON.stringify(user))
40
+ }
41
+
42
+ export function getGitHubAuthUrl(clientId: string, redirectUri: string): string {
43
+ const state = crypto.randomUUID()
44
+ sessionStorage.setItem('github_oauth_state', state)
45
+
46
+ const params = new URLSearchParams({
47
+ client_id: clientId,
48
+ redirect_uri: redirectUri,
49
+ scope: 'repo',
50
+ state,
51
+ })
52
+
53
+ return `https://github.com/login/oauth/authorize?${params.toString()}`
54
+ }
55
+
56
+ export function validateOAuthState(receivedState: string): boolean {
57
+ const storedState = sessionStorage.getItem('github_oauth_state')
58
+ sessionStorage.removeItem('github_oauth_state')
59
+ return storedState === receivedState
60
+ }
@@ -14,6 +14,7 @@ import { Route as SearchRouteImport } from './routes/search'
14
14
  import { Route as ReportsRouteImport } from './routes/reports'
15
15
  import { Route as PatternsRouteImport } from './routes/patterns'
16
16
  import { Route as MilestonesRouteImport } from './routes/milestones'
17
+ import { Route as GithubRouteImport } from './routes/github'
17
18
  import { Route as DesignsRouteImport } from './routes/designs'
18
19
  import { Route as ActivityRouteImport } from './routes/activity'
19
20
  import { Route as IndexRouteImport } from './routes/index'
@@ -28,6 +29,7 @@ import { Route as PatternsSlugRouteImport } from './routes/patterns.$slug'
28
29
  import { Route as MilestonesMilestoneIdRouteImport } from './routes/milestones.$milestoneId'
29
30
  import { Route as DesignsSlugRouteImport } from './routes/designs.$slug'
30
31
  import { Route as ApiWatchRouteImport } from './routes/api/watch'
32
+ import { Route as AuthGithubCallbackRouteImport } from './routes/auth/github/callback'
31
33
 
32
34
  const TasksRoute = TasksRouteImport.update({
33
35
  id: '/tasks',
@@ -54,6 +56,11 @@ const MilestonesRoute = MilestonesRouteImport.update({
54
56
  path: '/milestones',
55
57
  getParentRoute: () => rootRouteImport,
56
58
  } as any)
59
+ const GithubRoute = GithubRouteImport.update({
60
+ id: '/github',
61
+ path: '/github',
62
+ getParentRoute: () => rootRouteImport,
63
+ } as any)
57
64
  const DesignsRoute = DesignsRouteImport.update({
58
65
  id: '/designs',
59
66
  path: '/designs',
@@ -124,11 +131,17 @@ const ApiWatchRoute = ApiWatchRouteImport.update({
124
131
  path: '/api/watch',
125
132
  getParentRoute: () => rootRouteImport,
126
133
  } as any)
134
+ const AuthGithubCallbackRoute = AuthGithubCallbackRouteImport.update({
135
+ id: '/auth/github/callback',
136
+ path: '/auth/github/callback',
137
+ getParentRoute: () => rootRouteImport,
138
+ } as any)
127
139
 
128
140
  export interface FileRoutesByFullPath {
129
141
  '/': typeof IndexRoute
130
142
  '/activity': typeof ActivityRoute
131
143
  '/designs': typeof DesignsRouteWithChildren
144
+ '/github': typeof GithubRoute
132
145
  '/milestones': typeof MilestonesRouteWithChildren
133
146
  '/patterns': typeof PatternsRouteWithChildren
134
147
  '/reports': typeof ReportsRouteWithChildren
@@ -145,10 +158,12 @@ export interface FileRoutesByFullPath {
145
158
  '/patterns/': typeof PatternsIndexRoute
146
159
  '/reports/': typeof ReportsIndexRoute
147
160
  '/tasks/': typeof TasksIndexRoute
161
+ '/auth/github/callback': typeof AuthGithubCallbackRoute
148
162
  }
149
163
  export interface FileRoutesByTo {
150
164
  '/': typeof IndexRoute
151
165
  '/activity': typeof ActivityRoute
166
+ '/github': typeof GithubRoute
152
167
  '/search': typeof SearchRoute
153
168
  '/api/watch': typeof ApiWatchRoute
154
169
  '/designs/$slug': typeof DesignsSlugRoute
@@ -161,12 +176,14 @@ export interface FileRoutesByTo {
161
176
  '/patterns': typeof PatternsIndexRoute
162
177
  '/reports': typeof ReportsIndexRoute
163
178
  '/tasks': typeof TasksIndexRoute
179
+ '/auth/github/callback': typeof AuthGithubCallbackRoute
164
180
  }
165
181
  export interface FileRoutesById {
166
182
  __root__: typeof rootRouteImport
167
183
  '/': typeof IndexRoute
168
184
  '/activity': typeof ActivityRoute
169
185
  '/designs': typeof DesignsRouteWithChildren
186
+ '/github': typeof GithubRoute
170
187
  '/milestones': typeof MilestonesRouteWithChildren
171
188
  '/patterns': typeof PatternsRouteWithChildren
172
189
  '/reports': typeof ReportsRouteWithChildren
@@ -183,6 +200,7 @@ export interface FileRoutesById {
183
200
  '/patterns/': typeof PatternsIndexRoute
184
201
  '/reports/': typeof ReportsIndexRoute
185
202
  '/tasks/': typeof TasksIndexRoute
203
+ '/auth/github/callback': typeof AuthGithubCallbackRoute
186
204
  }
187
205
  export interface FileRouteTypes {
188
206
  fileRoutesByFullPath: FileRoutesByFullPath
@@ -190,6 +208,7 @@ export interface FileRouteTypes {
190
208
  | '/'
191
209
  | '/activity'
192
210
  | '/designs'
211
+ | '/github'
193
212
  | '/milestones'
194
213
  | '/patterns'
195
214
  | '/reports'
@@ -206,10 +225,12 @@ export interface FileRouteTypes {
206
225
  | '/patterns/'
207
226
  | '/reports/'
208
227
  | '/tasks/'
228
+ | '/auth/github/callback'
209
229
  fileRoutesByTo: FileRoutesByTo
210
230
  to:
211
231
  | '/'
212
232
  | '/activity'
233
+ | '/github'
213
234
  | '/search'
214
235
  | '/api/watch'
215
236
  | '/designs/$slug'
@@ -222,11 +243,13 @@ export interface FileRouteTypes {
222
243
  | '/patterns'
223
244
  | '/reports'
224
245
  | '/tasks'
246
+ | '/auth/github/callback'
225
247
  id:
226
248
  | '__root__'
227
249
  | '/'
228
250
  | '/activity'
229
251
  | '/designs'
252
+ | '/github'
230
253
  | '/milestones'
231
254
  | '/patterns'
232
255
  | '/reports'
@@ -243,18 +266,21 @@ export interface FileRouteTypes {
243
266
  | '/patterns/'
244
267
  | '/reports/'
245
268
  | '/tasks/'
269
+ | '/auth/github/callback'
246
270
  fileRoutesById: FileRoutesById
247
271
  }
248
272
  export interface RootRouteChildren {
249
273
  IndexRoute: typeof IndexRoute
250
274
  ActivityRoute: typeof ActivityRoute
251
275
  DesignsRoute: typeof DesignsRouteWithChildren
276
+ GithubRoute: typeof GithubRoute
252
277
  MilestonesRoute: typeof MilestonesRouteWithChildren
253
278
  PatternsRoute: typeof PatternsRouteWithChildren
254
279
  ReportsRoute: typeof ReportsRouteWithChildren
255
280
  SearchRoute: typeof SearchRoute
256
281
  TasksRoute: typeof TasksRouteWithChildren
257
282
  ApiWatchRoute: typeof ApiWatchRoute
283
+ AuthGithubCallbackRoute: typeof AuthGithubCallbackRoute
258
284
  }
259
285
 
260
286
  declare module '@tanstack/react-router' {
@@ -294,6 +320,13 @@ declare module '@tanstack/react-router' {
294
320
  preLoaderRoute: typeof MilestonesRouteImport
295
321
  parentRoute: typeof rootRouteImport
296
322
  }
323
+ '/github': {
324
+ id: '/github'
325
+ path: '/github'
326
+ fullPath: '/github'
327
+ preLoaderRoute: typeof GithubRouteImport
328
+ parentRoute: typeof rootRouteImport
329
+ }
297
330
  '/designs': {
298
331
  id: '/designs'
299
332
  path: '/designs'
@@ -392,6 +425,13 @@ declare module '@tanstack/react-router' {
392
425
  preLoaderRoute: typeof ApiWatchRouteImport
393
426
  parentRoute: typeof rootRouteImport
394
427
  }
428
+ '/auth/github/callback': {
429
+ id: '/auth/github/callback'
430
+ path: '/auth/github/callback'
431
+ fullPath: '/auth/github/callback'
432
+ preLoaderRoute: typeof AuthGithubCallbackRouteImport
433
+ parentRoute: typeof rootRouteImport
434
+ }
395
435
  }
396
436
  }
397
437
 
@@ -465,12 +505,14 @@ const rootRouteChildren: RootRouteChildren = {
465
505
  IndexRoute: IndexRoute,
466
506
  ActivityRoute: ActivityRoute,
467
507
  DesignsRoute: DesignsRouteWithChildren,
508
+ GithubRoute: GithubRoute,
468
509
  MilestonesRoute: MilestonesRouteWithChildren,
469
510
  PatternsRoute: PatternsRouteWithChildren,
470
511
  ReportsRoute: ReportsRouteWithChildren,
471
512
  SearchRoute: SearchRoute,
472
513
  TasksRoute: TasksRouteWithChildren,
473
514
  ApiWatchRoute: ApiWatchRoute,
515
+ AuthGithubCallbackRoute: AuthGithubCallbackRoute,
474
516
  }
475
517
  export const routeTree = rootRouteImport
476
518
  ._addFileChildren(rootRouteChildren)
@@ -1,13 +1,14 @@
1
- import { HeadContent, Scripts, createRootRoute, Outlet, useRouter, useRouterState } from '@tanstack/react-router'
1
+ import { HeadContent, Scripts, createRootRoute, Outlet } from '@tanstack/react-router'
2
2
  import { useState, useCallback, useEffect } from 'react'
3
3
  import { Menu, X } from 'lucide-react'
4
4
  import { useAutoRefresh } from '../lib/useAutoRefresh'
5
5
  import { Sidebar } from '../components/Sidebar'
6
6
  import { Header } from '../components/Header'
7
7
  import { SidePanel } from '../components/SidePanel'
8
- import { getProgressData } from '../services/progress-database.service'
8
+ import { getProgressData, type ProgressResult } from '../services/progress-database.service'
9
9
  import { listProjects, getProjectProgressPath } from '../services/projects.service'
10
- import { fetchGitHubProgress } from '../services/github.service'
10
+ import { fetchGitHubProgress, type GitHubResult } from '../services/github.service'
11
+ import { getStoredToken } from '../lib/github-auth'
11
12
  import type { ProgressData } from '../lib/types'
12
13
  import type { AcpProject } from '../services/projects.service'
13
14
  import { ProgressProvider } from '../contexts/ProgressContext'
@@ -28,7 +29,7 @@ export const Route = createRootRoute({
28
29
  if (!import.meta.env.VITE_HOSTED) {
29
30
  try {
30
31
  const [result, projectList] = await Promise.all([
31
- getProgressData({ data: {} }),
32
+ getProgressData({ data: {} }) as Promise<ProgressResult>,
32
33
  listProjects(),
33
34
  ])
34
35
  if (result.ok) {
@@ -120,7 +121,8 @@ function RootLayout() {
120
121
  const repoParam = getRepoFromUrl()
121
122
  // Repo param takes precedence over default local data
122
123
  if (repoParam) {
123
- fetchGitHubProgress({ data: repoParam }).then((result) => {
124
+ const token = getStoredToken()
125
+ void (fetchGitHubProgress({ data: { ...repoParam, token: token || undefined } }) as Promise<GitHubResult>).then((result) => {
124
126
  if (result.ok) {
125
127
  setProgressData(result.data)
126
128
  setCurrentProject(`${repoParam.owner}/${repoParam.repo}`)
@@ -130,7 +132,8 @@ function RootLayout() {
130
132
  }, [initialLoadDone])
131
133
 
132
134
  const handleGitHubLoad = useCallback(async (owner: string, repo: string) => {
133
- const result = await fetchGitHubProgress({ data: { owner, repo } })
135
+ const token = getStoredToken()
136
+ const result = await (fetchGitHubProgress({ data: { owner, repo, token: token || undefined } }) as Promise<GitHubResult>)
134
137
  if (result.ok) {
135
138
  setProgressData(result.data)
136
139
  setCurrentProject(`${owner}/${repo}`)
@@ -144,7 +147,7 @@ function RootLayout() {
144
147
  try {
145
148
  const path = await getProjectProgressPath({ data: { projectId } })
146
149
  if (path) {
147
- const result = await getProgressData({ data: { path } })
150
+ const result = await (getProgressData({ data: { path } }) as Promise<ProgressResult>)
148
151
  if (result.ok) {
149
152
  setProgressData(result.data)
150
153
  setCurrentProject(projectId)
@@ -0,0 +1,86 @@
1
+ import { createFileRoute, useNavigate } from '@tanstack/react-router'
2
+ import { useEffect, useState } from 'react'
3
+ import { exchangeOAuthCode, fetchGitHubUser } from '../../../services/github-oauth.service'
4
+ import { setStoredToken, setStoredUser, validateOAuthState } from '../../../lib/github-auth'
5
+
6
+ export const Route = createFileRoute('/auth/github/callback')({
7
+ component: GitHubCallback,
8
+ })
9
+
10
+ function GitHubCallback() {
11
+ const navigate = useNavigate()
12
+ const [error, setError] = useState<string | null>(null)
13
+
14
+ useEffect(() => {
15
+ const params = new URLSearchParams(window.location.search)
16
+ const code = params.get('code')
17
+ const state = params.get('state')
18
+ const errorParam = params.get('error')
19
+
20
+ if (errorParam) {
21
+ setError(`GitHub OAuth error: ${errorParam}`)
22
+ return
23
+ }
24
+
25
+ if (!code || !state) {
26
+ setError('Missing OAuth parameters')
27
+ return
28
+ }
29
+
30
+ if (!validateOAuthState(state)) {
31
+ setError('Invalid OAuth state - possible CSRF attack')
32
+ return
33
+ }
34
+
35
+ exchangeOAuthCode({ data: { code } })
36
+ .then(async (result) => {
37
+ if (!result.ok) {
38
+ setError(result.error)
39
+ return
40
+ }
41
+
42
+ // Store token
43
+ setStoredToken(result.token)
44
+
45
+ // Fetch user info
46
+ const userResult = await fetchGitHubUser({ data: { token: result.token } })
47
+ if (userResult.ok) {
48
+ setStoredUser(userResult.user)
49
+ }
50
+
51
+ // Redirect back to home
52
+ navigate({ to: '/' })
53
+ })
54
+ .catch((err) => {
55
+ setError(err instanceof Error ? err.message : 'Unknown error')
56
+ })
57
+ }, [navigate])
58
+
59
+ if (error) {
60
+ return (
61
+ <div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
62
+ <div className="text-center max-w-md p-6">
63
+ <h2 className="text-xl font-semibold text-red-600 dark:text-red-400 mb-2">
64
+ Authentication Failed
65
+ </h2>
66
+ <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">{error}</p>
67
+ <button
68
+ onClick={() => navigate({ to: '/' })}
69
+ className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
70
+ >
71
+ Return Home
72
+ </button>
73
+ </div>
74
+ </div>
75
+ )
76
+ }
77
+
78
+ return (
79
+ <div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
80
+ <div className="text-center">
81
+ <div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
82
+ <p className="text-sm text-gray-600 dark:text-gray-400">Connecting to GitHub...</p>
83
+ </div>
84
+ </div>
85
+ )
86
+ }
@@ -0,0 +1,213 @@
1
+ import { createFileRoute, useNavigate } from '@tanstack/react-router'
2
+ import { useState, useEffect } from 'react'
3
+ import { Github, Lock, Star, GitFork, Loader2, LogIn } from 'lucide-react'
4
+ import { getStoredToken, getStoredUser } from '../lib/github-auth'
5
+ import { searchGitHubRepos } from '../services/github-oauth.service'
6
+
7
+ export const Route = createFileRoute('/github')({
8
+ component: GitHubRepoList,
9
+ })
10
+
11
+ interface Repo {
12
+ full_name: string
13
+ description: string | null
14
+ private: boolean
15
+ stargazers_count?: number
16
+ forks_count?: number
17
+ language?: string | null
18
+ updated_at?: string
19
+ }
20
+
21
+ function GitHubRepoList() {
22
+ const navigate = useNavigate()
23
+ const [repos, setRepos] = useState<Repo[]>([])
24
+ const [filteredRepos, setFilteredRepos] = useState<Repo[]>([])
25
+ const [searchQuery, setSearchQuery] = useState('')
26
+ const [loading, setLoading] = useState(true)
27
+ const [error, setError] = useState<string | null>(null)
28
+
29
+ const token = getStoredToken()
30
+ const user = getStoredUser()
31
+
32
+ useEffect(() => {
33
+ if (!token) {
34
+ setLoading(false)
35
+ return
36
+ }
37
+
38
+ searchGitHubRepos({ data: { token, query: '' } })
39
+ .then((result) => {
40
+ if (result.ok) {
41
+ setRepos(result.repos as Repo[])
42
+ setFilteredRepos(result.repos as Repo[])
43
+ } else {
44
+ setError(result.error)
45
+ }
46
+ })
47
+ .catch((err) => {
48
+ setError(err instanceof Error ? err.message : 'Failed to load repos')
49
+ })
50
+ .finally(() => {
51
+ setLoading(false)
52
+ })
53
+ }, [token])
54
+
55
+ useEffect(() => {
56
+ if (!searchQuery.trim()) {
57
+ setFilteredRepos(repos)
58
+ return
59
+ }
60
+
61
+ const query = searchQuery.toLowerCase()
62
+ const filtered = repos.filter((repo) =>
63
+ repo.full_name.toLowerCase().includes(query) ||
64
+ (repo.description && repo.description.toLowerCase().includes(query))
65
+ )
66
+ setFilteredRepos(filtered)
67
+ }, [searchQuery, repos])
68
+
69
+ const handleRepoClick = async (repo: Repo) => {
70
+ const [owner, repoName] = repo.full_name.split('/')
71
+ window.location.href = `/?repo=${owner}/${repoName}`
72
+ }
73
+
74
+ if (!token) {
75
+ return (
76
+ <div className="flex items-center justify-center h-full">
77
+ <div className="text-center max-w-md p-6">
78
+ <Github className="w-16 h-16 mx-auto mb-4 text-gray-400" />
79
+ <h2 className="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-2">
80
+ Sign in Required
81
+ </h2>
82
+ <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
83
+ Sign in with GitHub to view and load your repositories.
84
+ </p>
85
+ <button
86
+ onClick={() => navigate({ to: '/' })}
87
+ className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
88
+ >
89
+ <LogIn className="w-4 h-4" />
90
+ Go to Home
91
+ </button>
92
+ </div>
93
+ </div>
94
+ )
95
+ }
96
+
97
+ if (loading) {
98
+ return (
99
+ <div className="flex items-center justify-center h-full">
100
+ <div className="text-center">
101
+ <Loader2 className="w-12 h-12 animate-spin mx-auto mb-4 text-blue-600" />
102
+ <p className="text-sm text-gray-600 dark:text-gray-400">Loading repositories...</p>
103
+ </div>
104
+ </div>
105
+ )
106
+ }
107
+
108
+ if (error) {
109
+ return (
110
+ <div className="flex items-center justify-center h-full">
111
+ <div className="text-center max-w-md p-6">
112
+ <h2 className="text-xl font-semibold text-red-600 dark:text-red-400 mb-2">
113
+ Error Loading Repositories
114
+ </h2>
115
+ <p className="text-sm text-gray-600 dark:text-gray-400">{error}</p>
116
+ </div>
117
+ </div>
118
+ )
119
+ }
120
+
121
+ return (
122
+ <div className="h-full flex flex-col">
123
+ {/* Header */}
124
+ <div className="border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 p-4 lg:p-6">
125
+ <div className="max-w-5xl mx-auto">
126
+ <div className="flex items-center gap-3 mb-4">
127
+ <Github className="w-6 h-6 text-gray-700 dark:text-gray-300" />
128
+ <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
129
+ GitHub Repositories
130
+ </h1>
131
+ </div>
132
+ {user && (
133
+ <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
134
+ Showing repositories for <span className="font-medium text-gray-800 dark:text-gray-200">@{user.login}</span>
135
+ </p>
136
+ )}
137
+ {/* Search */}
138
+ <input
139
+ type="text"
140
+ value={searchQuery}
141
+ onChange={(e) => setSearchQuery(e.target.value)}
142
+ placeholder="Search repositories..."
143
+ className="w-full px-4 py-2 text-base bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-800 rounded-md text-gray-900 dark:text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
144
+ />
145
+ </div>
146
+ </div>
147
+
148
+ {/* Repo List */}
149
+ <div className="flex-1 overflow-auto">
150
+ <div className="max-w-5xl mx-auto p-4 lg:p-6">
151
+ {filteredRepos.length === 0 ? (
152
+ <div className="text-center py-12">
153
+ <p className="text-gray-600 dark:text-gray-400">
154
+ {searchQuery ? 'No repositories match your search.' : 'No repositories found.'}
155
+ </p>
156
+ </div>
157
+ ) : (
158
+ <div className="space-y-3">
159
+ {filteredRepos.map((repo) => (
160
+ <button
161
+ key={repo.full_name}
162
+ onClick={() => handleRepoClick(repo)}
163
+ className="w-full text-left p-4 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg hover:border-gray-300 dark:hover:border-gray-700 hover:shadow-md transition-all"
164
+ >
165
+ <div className="flex items-start justify-between gap-4">
166
+ <div className="flex-1 min-w-0">
167
+ <div className="flex items-center gap-2 mb-2">
168
+ <h3 className="text-base font-semibold text-blue-600 dark:text-blue-400 truncate">
169
+ {repo.full_name}
170
+ </h3>
171
+ {repo.private && (
172
+ <span className="flex items-center gap-1 px-2 py-0.5 text-xs bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-500 rounded-full shrink-0">
173
+ <Lock className="w-3 h-3" />
174
+ Private
175
+ </span>
176
+ )}
177
+ </div>
178
+ {repo.description && (
179
+ <p className="text-sm text-gray-600 dark:text-gray-400 mb-2 line-clamp-2">
180
+ {repo.description}
181
+ </p>
182
+ )}
183
+ <div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-500">
184
+ {repo.language && (
185
+ <span className="flex items-center gap-1">
186
+ <span className="w-3 h-3 rounded-full bg-blue-500" />
187
+ {repo.language}
188
+ </span>
189
+ )}
190
+ {repo.stargazers_count !== undefined && (
191
+ <span className="flex items-center gap-1">
192
+ <Star className="w-3 h-3" />
193
+ {repo.stargazers_count}
194
+ </span>
195
+ )}
196
+ {repo.forks_count !== undefined && (
197
+ <span className="flex items-center gap-1">
198
+ <GitFork className="w-3 h-3" />
199
+ {repo.forks_count}
200
+ </span>
201
+ )}
202
+ </div>
203
+ </div>
204
+ </div>
205
+ </button>
206
+ ))}
207
+ </div>
208
+ )}
209
+ </div>
210
+ </div>
211
+ </div>
212
+ )
213
+ }
@@ -0,0 +1,128 @@
1
+ import { createServerFn } from '@tanstack/react-start'
2
+ import type { GitHubUser } from '../lib/github-auth'
3
+
4
+ export type OAuthTokenResult =
5
+ | { ok: true; token: string }
6
+ | { ok: false; error: string }
7
+
8
+ export type GitHubUserResult =
9
+ | { ok: true; user: GitHubUser }
10
+ | { ok: false; error: string }
11
+
12
+ export type GitHubRepoSearchResult =
13
+ | { ok: true; repos: Array<{ full_name: string; description: string | null; private: boolean; stargazers_count?: number; forks_count?: number; language?: string | null; updated_at?: string }> }
14
+ | { ok: false; error: string }
15
+
16
+ export const exchangeOAuthCode = createServerFn({ method: 'POST' })
17
+ .inputValidator((input: { code: string }) => input)
18
+ .handler(async ({ data: input }): Promise<OAuthTokenResult> => {
19
+ const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID || ''
20
+ const clientSecret = import.meta.env.VITE_GITHUB_CLIENT_SECRET || ''
21
+
22
+ if (!clientId || !clientSecret) {
23
+ return { ok: false, error: 'GitHub OAuth not configured' }
24
+ }
25
+
26
+ try {
27
+ const response = await fetch('https://github.com/login/oauth/access_token', {
28
+ method: 'POST',
29
+ headers: {
30
+ 'Content-Type': 'application/json',
31
+ 'Accept': 'application/json',
32
+ },
33
+ body: JSON.stringify({
34
+ client_id: clientId,
35
+ client_secret: clientSecret,
36
+ code: input.code,
37
+ }),
38
+ })
39
+
40
+ if (!response.ok) {
41
+ return { ok: false, error: `OAuth token exchange failed: ${response.statusText}` }
42
+ }
43
+
44
+ const data = await response.json() as { access_token?: string; error?: string }
45
+
46
+ if (data.error || !data.access_token) {
47
+ return { ok: false, error: data.error || 'No access token returned' }
48
+ }
49
+
50
+ return { ok: true, token: data.access_token }
51
+ } catch (err) {
52
+ return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' }
53
+ }
54
+ })
55
+
56
+ export const fetchGitHubUser = createServerFn({ method: 'GET' })
57
+ .inputValidator((input: { token: string }) => input)
58
+ .handler(async ({ data: input }): Promise<GitHubUserResult> => {
59
+ try {
60
+ const response = await fetch('https://api.github.com/user', {
61
+ headers: {
62
+ 'Authorization': `token ${input.token}`,
63
+ 'User-Agent': 'acp-visualizer',
64
+ },
65
+ })
66
+
67
+ if (!response.ok) {
68
+ return { ok: false, error: `Failed to fetch user: ${response.statusText}` }
69
+ }
70
+
71
+ const data = await response.json() as { login: string; name: string | null; avatar_url: string }
72
+
73
+ return {
74
+ ok: true,
75
+ user: {
76
+ login: data.login,
77
+ name: data.name,
78
+ avatar_url: data.avatar_url,
79
+ },
80
+ }
81
+ } catch (err) {
82
+ return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' }
83
+ }
84
+ })
85
+
86
+ export const searchGitHubRepos = createServerFn({ method: 'GET' })
87
+ .inputValidator((input: { token: string; query: string }) => input)
88
+ .handler(async ({ data: input }): Promise<GitHubRepoSearchResult> => {
89
+ try {
90
+ const response = await fetch(
91
+ `https://api.github.com/user/repos?per_page=100&sort=updated`,
92
+ {
93
+ headers: {
94
+ 'Authorization': `token ${input.token}`,
95
+ 'User-Agent': 'acp-visualizer',
96
+ },
97
+ }
98
+ )
99
+
100
+ if (!response.ok) {
101
+ return { ok: false, error: `Failed to fetch repos: ${response.statusText}` }
102
+ }
103
+
104
+ const data = await response.json() as Array<{
105
+ full_name: string
106
+ description: string | null
107
+ private: boolean
108
+ stargazers_count?: number
109
+ forks_count?: number
110
+ language?: string | null
111
+ updated_at?: string
112
+ }>
113
+
114
+ // Filter by query
115
+ const filtered = input.query
116
+ ? data.filter((r) =>
117
+ r.full_name.toLowerCase().includes(input.query.toLowerCase())
118
+ )
119
+ : data
120
+
121
+ return {
122
+ ok: true,
123
+ repos: filtered.slice(0, 20),
124
+ }
125
+ } catch (err) {
126
+ return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' }
127
+ }
128
+ })
@@ -124,7 +124,7 @@ export type AgentFile = {
124
124
  }
125
125
 
126
126
  export type ListDirResult =
127
- | { ok: true; files: AgentFile[] }
127
+ | { ok: true; files: AgentFile[]; directoryExists?: boolean }
128
128
  | { ok: false; files: []; error: string }
129
129
 
130
130
  export const listAgentDirectory = createServerFn({ method: 'GET' })
@@ -197,7 +197,7 @@ async function listDirFromGitHub(
197
197
 
198
198
  if (!response.ok) {
199
199
  if (response.status === 404) {
200
- return { ok: true, files: [] }
200
+ return { ok: true, files: [], directoryExists: false }
201
201
  }
202
202
  return { ok: false, files: [], error: `GitHub returned ${response.status}` }
203
203
  }