diff --git a/app/api/github/user-repos/route.ts b/app/api/github/user-repos/route.ts
new file mode 100644
index 0000000..515d980
--- /dev/null
+++ b/app/api/github/user-repos/route.ts
@@ -0,0 +1,137 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getUserGitHubToken } from '@/lib/github/user-token'
+
+interface GitHubRepo {
+ name: string
+ full_name: string
+ description?: string
+ private: boolean
+ clone_url: string
+ updated_at: string
+ language?: string
+ owner: {
+ login: string
+ }
+}
+
+interface GitHubSearchResult {
+ total_count: number
+ incomplete_results: boolean
+ items: GitHubRepo[]
+}
+
+export async function GET(request: NextRequest) {
+ try {
+ const token = await getUserGitHubToken(request)
+
+ if (!token) {
+ return NextResponse.json({ error: 'GitHub not connected' }, { status: 401 })
+ }
+
+ const { searchParams } = new URL(request.url)
+ const page = parseInt(searchParams.get('page') || '1', 10)
+ const perPage = parseInt(searchParams.get('per_page') || '25', 10)
+ const search = searchParams.get('search') || ''
+
+ // Get authenticated user
+ const userResponse = await fetch('https://api.github.com/user', {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: 'application/vnd.github.v3+json',
+ },
+ })
+
+ if (!userResponse.ok) {
+ return NextResponse.json({ error: 'Failed to fetch user' }, { status: 401 })
+ }
+
+ const user = await userResponse.json()
+ const username = user.login
+
+ // If there's a search query, use GitHub search API
+ if (search.trim()) {
+ // Search for repos the user has access to matching the query
+ const searchQuery = encodeURIComponent(`${search} in:name user:${username} fork:true`)
+ const searchUrl = `https://api.github.com/search/repositories?q=${searchQuery}&sort=updated&order=desc&per_page=${perPage}&page=${page}`
+
+ const searchResponse = await fetch(searchUrl, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: 'application/vnd.github.v3+json',
+ },
+ })
+
+ if (!searchResponse.ok) {
+ throw new Error('Failed to search repositories')
+ }
+
+ const searchResult: GitHubSearchResult = await searchResponse.json()
+
+ return NextResponse.json({
+ repos: searchResult.items.map((repo) => ({
+ name: repo.name,
+ full_name: repo.full_name,
+ owner: repo.owner.login,
+ description: repo.description,
+ private: repo.private,
+ clone_url: repo.clone_url,
+ updated_at: repo.updated_at,
+ language: repo.language,
+ })),
+ page,
+ per_page: perPage,
+ has_more: searchResult.total_count > page * perPage,
+ total_count: searchResult.total_count,
+ username,
+ })
+ }
+
+ // No search query - fetch repos sorted by updated_at (most recent first) for pagination
+ // We use a larger page size and handle deduplication ourselves
+ const githubPerPage = 100
+ const githubPage = Math.ceil((page * perPage) / githubPerPage)
+
+ // Fetch user's repos (owned repos, sorted by recently updated)
+ const apiUrl = `https://api.github.com/user/repos?sort=updated&direction=desc&per_page=${githubPerPage}&page=${githubPage}&visibility=all&affiliation=owner,organization_member`
+
+ const response = await fetch(apiUrl, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: 'application/vnd.github.v3+json',
+ },
+ })
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch repositories')
+ }
+
+ const repos: GitHubRepo[] = await response.json()
+
+ // Calculate the offset within the GitHub page
+ const offsetInGithubPage = ((page - 1) * perPage) % githubPerPage
+ const slicedRepos = repos.slice(offsetInGithubPage, offsetInGithubPage + perPage)
+
+ // Check if there are more repos
+ const hasMore = repos.length === githubPerPage || slicedRepos.length === perPage
+
+ return NextResponse.json({
+ repos: slicedRepos.map((repo) => ({
+ name: repo.name,
+ full_name: repo.full_name,
+ owner: repo.owner.login,
+ description: repo.description,
+ private: repo.private,
+ clone_url: repo.clone_url,
+ updated_at: repo.updated_at,
+ language: repo.language,
+ })),
+ page,
+ per_page: perPage,
+ has_more: hasMore,
+ username,
+ })
+ } catch (error) {
+ console.error('Error fetching user repositories:', error)
+ return NextResponse.json({ error: 'Failed to fetch repositories' }, { status: 500 })
+ }
+}
diff --git a/components/app-layout.tsx b/components/app-layout.tsx
index 2dc156a..991c27d 100644
--- a/components/app-layout.tsx
+++ b/components/app-layout.tsx
@@ -46,12 +46,26 @@ export const useTasks = () => {
function SidebarLoader({ width }: { width: number }) {
return (
-
+ {/* Tabs */}
+
+
+
+
- {/* Tabs */}
-
-
-
-
diff --git a/components/task-sidebar.tsx b/components/task-sidebar.tsx
index 5fb35e4..6f06f58 100644
--- a/components/task-sidebar.tsx
+++ b/components/task-sidebar.tsx
@@ -3,7 +3,7 @@
import { Task } from '@/lib/db/schema'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
-import { AlertCircle, Plus, Trash2, GitBranch } from 'lucide-react'
+import { AlertCircle, Plus, Trash2, GitBranch, Loader2, Search, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
@@ -19,13 +19,15 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Checkbox } from '@/components/ui/checkbox'
-import { useState, useMemo } from 'react'
+import { Input } from '@/components/ui/input'
+import { useState, useMemo, useEffect, useRef, useCallback } from 'react'
import { toast } from 'sonner'
import { useTasks } from '@/components/app-layout'
import { useAtomValue } from 'jotai'
import { sessionAtom } from '@/lib/atoms/session'
import { PRStatusIcon } from '@/components/pr-status-icon'
import { PRCheckStatus } from '@/components/pr-check-status'
+import { githubConnectionAtom } from '@/lib/atoms/github-connection'
// Model mappings for human-friendly names
const AGENT_MODELS = {
@@ -81,18 +83,22 @@ interface TaskSidebarProps {
type TabType = 'tasks' | 'repos'
-interface RepoInfo {
- url: string
- owner: string
+interface GitHubRepoInfo {
name: string
- taskCount: number
- lastUsed: Date
+ full_name: string
+ owner: string
+ description?: string
+ private: boolean
+ clone_url: string
+ updated_at: string
+ language?: string
}
export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) {
const pathname = usePathname()
const { refreshTasks, toggleSidebar } = useTasks()
const session = useAtomValue(sessionAtom)
+ const githubConnection = useAtomValue(githubConnectionAtom)
const [isDeleting, setIsDeleting] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [deleteCompleted, setDeleteCompleted] = useState(true)
@@ -100,6 +106,20 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) {
const [deleteStopped, setDeleteStopped] = useState(true)
const [activeTab, setActiveTab] = useState
('tasks')
+ // State for repos from API
+ const [repos, setRepos] = useState([])
+ const [reposLoading, setReposLoading] = useState(false)
+ const [reposPage, setReposPage] = useState(1)
+ const [hasMoreRepos, setHasMoreRepos] = useState(true)
+ const [reposInitialized, setReposInitialized] = useState(false)
+ const [repoSearchQuery, setRepoSearchQuery] = useState('')
+ const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
+ const [searchResults, setSearchResults] = useState([])
+ const [searchLoading, setSearchLoading] = useState(false)
+ const [searchPage, setSearchPage] = useState(1)
+ const [searchHasMore, setSearchHasMore] = useState(false)
+ const loadMoreRef = useRef(null)
+
// Close sidebar on mobile when clicking any link
const handleLinkClick = () => {
if (typeof window !== 'undefined' && window.innerWidth < 1024) {
@@ -107,9 +127,9 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) {
}
}
- // Extract unique repositories from tasks
- const repositories = useMemo(() => {
- const repoMap = new Map()
+ // Extract task counts per repo from tasks
+ const taskCountByRepo = useMemo(() => {
+ const counts = new Map()
tasks.forEach((task) => {
if (task.repoUrl) {
@@ -120,23 +140,7 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) {
const owner = pathParts[0]
const name = pathParts[1].replace(/\.git$/, '')
const repoKey = `${owner}/${name}`
-
- if (repoMap.has(repoKey)) {
- const existing = repoMap.get(repoKey)!
- existing.taskCount++
- const taskCreatedAt = new Date(task.createdAt)
- if (taskCreatedAt > existing.lastUsed) {
- existing.lastUsed = taskCreatedAt
- }
- } else {
- repoMap.set(repoKey, {
- url: task.repoUrl,
- owner,
- name,
- taskCount: 1,
- lastUsed: new Date(task.createdAt),
- })
- }
+ counts.set(repoKey, (counts.get(repoKey) || 0) + 1)
}
} catch {
// Invalid URL, skip
@@ -144,10 +148,161 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) {
}
})
- // Sort by last used (most recent first)
- return Array.from(repoMap.values()).sort((a, b) => b.lastUsed.getTime() - a.lastUsed.getTime())
+ return counts
}, [tasks])
+ // Debounce search query
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebouncedSearchQuery(repoSearchQuery)
+ }, 300)
+
+ return () => clearTimeout(timer)
+ }, [repoSearchQuery])
+
+ // Fetch search results when debounced query changes
+ const fetchSearchResults = useCallback(async (query: string, page: number, append: boolean = false) => {
+ if (!query.trim()) {
+ setSearchResults([])
+ setSearchHasMore(false)
+ return
+ }
+
+ setSearchLoading(true)
+ try {
+ const response = await fetch(
+ `/api/github/user-repos?page=${page}&per_page=25&search=${encodeURIComponent(query)}`,
+ )
+
+ if (!response.ok) {
+ throw new Error('Failed to search repos')
+ }
+
+ const data = await response.json()
+
+ if (append) {
+ setSearchResults((prev) => [...prev, ...data.repos])
+ } else {
+ setSearchResults(data.repos)
+ }
+
+ setSearchHasMore(data.has_more)
+ setSearchPage(page)
+ } catch (error) {
+ console.error('Error searching repos:', error)
+ } finally {
+ setSearchLoading(false)
+ }
+ }, [])
+
+ // Fetch search results when debounced query changes
+ useEffect(() => {
+ if (debouncedSearchQuery.trim()) {
+ fetchSearchResults(debouncedSearchQuery, 1)
+ } else {
+ setSearchResults([])
+ setSearchHasMore(false)
+ }
+ }, [debouncedSearchQuery, fetchSearchResults])
+
+ // Get the repos to display (search results or regular repos)
+ const displayedRepos = debouncedSearchQuery.trim() ? searchResults : repos
+ const displayedHasMore = debouncedSearchQuery.trim() ? searchHasMore : hasMoreRepos
+ const isSearching = debouncedSearchQuery.trim().length > 0
+
+ // Fetch repos from API
+ const fetchRepos = useCallback(
+ async (page: number, append: boolean = false) => {
+ if (reposLoading) return
+
+ setReposLoading(true)
+ try {
+ const response = await fetch(`/api/github/user-repos?page=${page}&per_page=25`)
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch repos')
+ }
+
+ const data = await response.json()
+
+ if (append) {
+ setRepos((prev) => [...prev, ...data.repos])
+ } else {
+ setRepos(data.repos)
+ }
+
+ setHasMoreRepos(data.has_more)
+ setReposPage(page)
+ setReposInitialized(true)
+ } catch (error) {
+ console.error('Error fetching repos:', error)
+ } finally {
+ setReposLoading(false)
+ }
+ },
+ [reposLoading],
+ )
+
+ // Load repos when switching to repos tab or when GitHub is connected
+ useEffect(() => {
+ if (activeTab === 'repos' && session.user && githubConnection.connected && !reposInitialized && !reposLoading) {
+ fetchRepos(1)
+ }
+ }, [activeTab, session.user, githubConnection.connected, reposInitialized, reposLoading, fetchRepos])
+
+ // Reset repos when GitHub connection changes
+ useEffect(() => {
+ if (!githubConnection.connected) {
+ setRepos([])
+ setReposPage(1)
+ setHasMoreRepos(true)
+ setReposInitialized(false)
+ }
+ }, [githubConnection.connected])
+
+ // Infinite scroll observer
+ useEffect(() => {
+ const isLoading = isSearching ? searchLoading : reposLoading
+ const hasMore = displayedHasMore
+
+ if (activeTab !== 'repos' || !hasMore || isLoading) return
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0].isIntersecting && hasMore && !isLoading) {
+ if (isSearching) {
+ fetchSearchResults(debouncedSearchQuery, searchPage + 1, true)
+ } else {
+ fetchRepos(reposPage + 1, true)
+ }
+ }
+ },
+ { threshold: 0.1 },
+ )
+
+ const currentRef = loadMoreRef.current
+ if (currentRef) {
+ observer.observe(currentRef)
+ }
+
+ return () => {
+ if (currentRef) {
+ observer.unobserve(currentRef)
+ }
+ }
+ }, [
+ activeTab,
+ displayedHasMore,
+ reposLoading,
+ searchLoading,
+ reposPage,
+ searchPage,
+ isSearching,
+ debouncedSearchQuery,
+ fetchRepos,
+ fetchSearchResults,
+ ])
+
const handleDeleteTasks = async () => {
if (!deleteCompleted && !deleteFailed && !deleteStopped) {
toast.error('Please select at least one task type to delete')
@@ -450,49 +605,112 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) {
{/* Repos Tab Content */}
{activeTab === 'repos' && (
-
- {repositories.length === 0 ? (
-
-
- No repositories yet. Create a task with a repository!
-
-
- ) : (
- repositories.map((repo) => {
- const repoPath = `/repos/${repo.owner}/${repo.name}`
- const isActive = pathname === repoPath || pathname.startsWith(repoPath + '/')
-
- return (
-
+ {/* Search input */}
+ {githubConnection.connected && (repos.length > 0 || repoSearchQuery) && (
+
+
+
setRepoSearchQuery(e.target.value)}
+ className="h-8 pl-7 pr-7 text-xs"
+ />
+ {repoSearchQuery && (
+
+ )}
+
)}
+
+
+ {!githubConnection.connected ? (
+
+
+ Connect GitHub to view your repositories
+
+
+ ) : (reposLoading && repos.length === 0 && !isSearching) ||
+ (searchLoading && searchResults.length === 0 && isSearching) ? (
+
+
+
+ {isSearching ? 'Searching...' : 'Loading repositories...'}
+
+
+ ) : displayedRepos.length === 0 && !isSearching ? (
+
+
+ No repositories found
+
+
+ ) : displayedRepos.length === 0 && isSearching && !searchLoading ? (
+
+
+ No repos match "{repoSearchQuery}"
+
+
+ ) : (
+ <>
+ {displayedRepos.map((repo) => {
+ const repoPath = `/repos/${repo.owner}/${repo.name}`
+ const isActive = pathname === repoPath || pathname.startsWith(repoPath + '/')
+ const repoKey = `${repo.owner}/${repo.name}`
+ const taskCount = taskCountByRepo.get(repoKey) || 0
+
+ return (
+
+
+
+
+
+
+
+ {repo.owner}/{repo.name}
+
+ {taskCount > 0 && (
+
+ {taskCount} {taskCount === 1 ? 'task' : 'tasks'}
+
+ )}
+
+ {repo.private && (
+
+ Private
+
+ )}
+
+
+
+
+ )
+ })}
+ {/* Load more trigger */}
+ {displayedHasMore && (
+
+ {(isSearching ? searchLoading : reposLoading) && (
+
+ )}
+
+ )}
+ >
+ )}
+
)}