diff --git a/components/home-page-content.tsx b/components/home-page-content.tsx index 1ed9e0cc..72843b26 100644 --- a/components/home-page-content.tsx +++ b/components/home-page-content.tsx @@ -13,9 +13,10 @@ import { Button } from '@/components/ui/button' import { redirectToSignIn } from '@/lib/session/redirect-to-sign-in' import { GitHubIcon } from '@/components/icons/github-icon' import { getEnabledAuthProviders } from '@/lib/auth/providers' -import { useSetAtom } from 'jotai' +import { useSetAtom, useAtom, useAtomValue } from 'jotai' import { taskPromptAtom } from '@/lib/atoms/task' import { HomePageMobileFooter } from '@/components/home-page-mobile-footer' +import { multiRepoModeAtom, selectedReposAtom } from '@/lib/atoms/multi-repo' interface HomePageContentProps { initialSelectedOwner?: string @@ -49,6 +50,10 @@ export function HomePageContent({ const { refreshTasks, addTaskOptimistically } = useTasks() const setTaskPrompt = useSetAtom(taskPromptAtom) + // Multi-repo mode state + const multiRepoMode = useAtomValue(multiRepoModeAtom) + const [selectedRepos, setSelectedRepos] = useAtom(selectedReposAtom) + // Check which auth providers are enabled const { github: hasGitHub, vercel: hasVercel } = getEnabledAuthProviders() @@ -132,12 +137,22 @@ export function HomePageContent({ return } - // Check if user has selected a repository - if (!data.repoUrl) { - toast.error('Please select a repository', { - description: 'Choose a GitHub repository to work with from the header.', - }) - return + // Check if multi-repo mode is enabled + if (multiRepoMode) { + if (selectedRepos.length === 0) { + toast.error('Please select repositories', { + description: 'Click on "0 repos selected" to choose repositories.', + }) + return + } + } else { + // Check if user has selected a repository + if (!data.repoUrl) { + toast.error('Please select a repository', { + description: 'Choose a GitHub repository to work with from the header.', + }) + return + } } // Clear the saved prompt since we're actually submitting it now @@ -145,6 +160,75 @@ export function HomePageContent({ setIsSubmitting(true) + // Check if this is multi-repo mode + if (multiRepoMode && selectedRepos.length > 0) { + // Create multiple tasks, one for each selected repo + const taskIds: string[] = [] + const tasksData = selectedRepos.map((repo) => { + const { id } = addTaskOptimistically({ + prompt: data.prompt, + repoUrl: repo.clone_url, + selectedAgent: data.selectedAgent, + selectedModel: data.selectedModel, + installDependencies: data.installDependencies, + maxDuration: data.maxDuration, + }) + taskIds.push(id) + return { + id, + prompt: data.prompt, + repoUrl: repo.clone_url, + selectedAgent: data.selectedAgent, + selectedModel: data.selectedModel, + installDependencies: data.installDependencies, + maxDuration: data.maxDuration, + keepAlive: data.keepAlive, + } + }) + + // Navigate to the first task + router.push(`/tasks/${taskIds[0]}`) + + try { + // Create all tasks in parallel + const responses = await Promise.all( + tasksData.map((taskData) => + fetch('/api/tasks', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(taskData), + }), + ), + ) + + const successCount = responses.filter((r) => r.ok).length + const failCount = responses.length - successCount + + if (successCount === responses.length) { + toast.success(`${successCount} tasks created successfully!`) + } else if (successCount > 0) { + toast.warning(`${successCount} tasks created, ${failCount} failed`) + } else { + toast.error('Failed to create tasks') + } + + // Clear selected repos after creating tasks + setSelectedRepos([]) + + // Refresh sidebar to get the real task data from server + await refreshTasks() + } catch (error) { + console.error('Error creating tasks:', error) + toast.error('Failed to create tasks') + await refreshTasks() + } finally { + setIsSubmitting(false) + } + return + } + // Check if this is multi-agent mode with multiple models selected const isMultiAgent = data.selectedAgent === 'multi-agent' && data.selectedModels && data.selectedModels.length > 0 diff --git a/components/home-page-header.tsx b/components/home-page-header.tsx index 329bb6c8..2f547343 100644 --- a/components/home-page-header.tsx +++ b/components/home-page-header.tsx @@ -24,6 +24,7 @@ import { githubConnectionAtom, githubConnectionInitializedAtom } from '@/lib/ato import { GitHubIcon } from '@/components/icons/github-icon' import { GitHubStarsButton } from '@/components/github-stars-button' import { OpenRepoUrlDialog } from '@/components/open-repo-url-dialog' +import { MultiRepoDialog } from '@/components/multi-repo-dialog' import { useTasks as useTasksContext } from '@/components/app-layout' interface HomePageHeaderProps { @@ -50,6 +51,7 @@ export function HomePageHeader({ const setGitHubConnection = useSetAtom(githubConnectionAtom) const [isRefreshing, setIsRefreshing] = useState(false) const [showOpenRepoDialog, setShowOpenRepoDialog] = useState(false) + const [showMultiRepoDialog, setShowMultiRepoDialog] = useState(false) const { addTaskOptimistically } = useTasksContext() const handleRefreshOwners = async () => { @@ -249,6 +251,7 @@ export function HomePageHeader({ onOwnerChange={onOwnerChange} onRepoChange={onRepoChange} size="sm" + onMultiRepoClick={() => setShowMultiRepoDialog(true)} /> @@ -315,6 +318,7 @@ export function HomePageHeader({ leftActions={leftActions} /> + ) } diff --git a/components/multi-repo-dialog.tsx b/components/multi-repo-dialog.tsx new file mode 100644 index 00000000..e49acc30 --- /dev/null +++ b/components/multi-repo-dialog.tsx @@ -0,0 +1,229 @@ +'use client' + +import { useState, useEffect, useRef, useMemo } from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { X, Search, Lock, Loader2 } from 'lucide-react' +import { useAtom, useAtomValue } from 'jotai' +import { selectedReposAtom, type SelectedRepo } from '@/lib/atoms/multi-repo' +import { githubOwnersAtom } from '@/lib/atoms/github-cache' + +interface GitHubRepo { + name: string + full_name: string + description: string + private: boolean + clone_url: string + language: string +} + +interface RepoWithOwner extends GitHubRepo { + owner: string +} + +interface MultiRepoDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function MultiRepoDialog({ open, onOpenChange }: MultiRepoDialogProps) { + const [selectedRepos, setSelectedRepos] = useAtom(selectedReposAtom) + const owners = useAtomValue(githubOwnersAtom) + const [searchQuery, setSearchQuery] = useState('') + const [allRepos, setAllRepos] = useState([]) + const [loadingRepos, setLoadingRepos] = useState(false) + const [showDropdown, setShowDropdown] = useState(false) + const inputRef = useRef(null) + const dropdownRef = useRef(null) + + // Load repos from all owners when dialog opens + useEffect(() => { + if (open && owners && owners.length > 0 && allRepos.length === 0) { + const loadAllRepos = async () => { + setLoadingRepos(true) + try { + const repoPromises = owners.map(async (owner) => { + try { + const response = await fetch(`/api/github/repos?owner=${owner.login}`) + if (response.ok) { + const repos: GitHubRepo[] = await response.json() + return repos.map((repo) => ({ ...repo, owner: owner.login })) + } + } catch (error) { + console.error('Error loading repos for owner:', error) + } + return [] + }) + + const results = await Promise.all(repoPromises) + const combinedRepos = results.flat() + setAllRepos(combinedRepos) + } catch (error) { + console.error('Error loading repos:', error) + } finally { + setLoadingRepos(false) + } + } + loadAllRepos() + } + }, [open, owners, allRepos.length]) + + // Filter repos based on search query and exclude already selected repos + const filteredRepos = useMemo(() => { + if (!allRepos.length) return [] + + const query = searchQuery.toLowerCase() + return allRepos.filter( + (repo) => + // Match search query against full_name, name, or description + (repo.full_name.toLowerCase().includes(query) || + repo.name.toLowerCase().includes(query) || + repo.description?.toLowerCase().includes(query)) && + // Exclude already selected repos + !selectedRepos.some((r) => r.full_name === repo.full_name), + ) + }, [allRepos, searchQuery, selectedRepos]) + + // Handle repo selection + const handleSelectRepo = (repo: RepoWithOwner) => { + const newRepo: SelectedRepo = { + owner: repo.owner, + repo: repo.name, + full_name: repo.full_name, + clone_url: repo.clone_url, + } + + setSelectedRepos([...selectedRepos, newRepo]) + setSearchQuery('') + setShowDropdown(false) + inputRef.current?.focus() + } + + // Handle repo removal + const handleRemoveRepo = (fullName: string) => { + setSelectedRepos(selectedRepos.filter((r) => r.full_name !== fullName)) + } + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + inputRef.current && + !inputRef.current.contains(event.target as Node) + ) { + setShowDropdown(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + return ( + + + + Select Repositories + + Choose multiple repositories to create tasks for. A separate task will be created for each selected + repository. + + + +
+ {/* Selected repos - shown above search so dropdown doesn't cover them */} + {selectedRepos.length > 0 && ( +
+
+ Selected ({selectedRepos.length}): + +
+
+ {selectedRepos.map((repo) => ( + + {repo.full_name} + + + ))} +
+
+ )} + + {/* Search input */} +
+ + { + setSearchQuery(e.target.value) + setShowDropdown(true) + }} + onFocus={() => setShowDropdown(true)} + className="pl-9" + /> + + {/* Dropdown */} + {showDropdown && ( +
+ {loadingRepos ? ( +
+ + Loading repositories... +
+ ) : filteredRepos.length === 0 ? ( +
+ {searchQuery ? `No repositories match "${searchQuery}"` : 'No repositories found'} +
+ ) : ( + filteredRepos.slice(0, 50).map((repo) => ( + + )) + )} + {filteredRepos.length > 50 && ( +
+ Showing first 50 of {filteredRepos.length} repositories. Use search to find more. +
+ )} +
+ )} +
+
+ +
+ + +
+
+
+ ) +} diff --git a/components/repo-selector.tsx b/components/repo-selector.tsx index d356c393..a3fa030f 100644 --- a/components/repo-selector.tsx +++ b/components/repo-selector.tsx @@ -4,10 +4,11 @@ import { useState, useEffect, useRef } from 'react' import Image from 'next/image' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Lock, Loader2 } from 'lucide-react' +import { Lock, Loader2, Layers } from 'lucide-react' import { useAtomValue, useSetAtom, useAtom } from 'jotai' import { githubConnectionAtom } from '@/lib/atoms/github-connection' import { githubOwnersAtom, githubReposAtomFamily } from '@/lib/atoms/github-cache' +import { multiRepoModeAtom, selectedReposAtom } from '@/lib/atoms/multi-repo' interface GitHubOwner { login: string @@ -31,6 +32,7 @@ interface RepoSelectorProps { onRepoChange: (repo: string) => void disabled?: boolean size?: 'sm' | 'default' + onMultiRepoClick?: () => void } export function RepoSelector({ @@ -40,6 +42,7 @@ export function RepoSelector({ onRepoChange, disabled = false, size = 'default', + onMultiRepoClick, }: RepoSelectorProps) { const [repoFilter, setRepoFilter] = useState('') // Initialize with selected owner to prevent flash @@ -52,6 +55,10 @@ export function RepoSelector({ const [temporaryOwner, setTemporaryOwner] = useState(null) const [temporaryRepo, setTemporaryRepo] = useState(null) + // Multi-repo mode state + const [multiRepoMode, setMultiRepoMode] = useAtom(multiRepoModeAtom) + const selectedRepos = useAtomValue(selectedReposAtom) + // Ref for the filter input to focus it when dropdown opens const filterInputRef = useRef(null) @@ -407,6 +414,16 @@ export function RepoSelector({ } const handleOwnerChange = (value: string) => { + if (value === '__many__') { + // Enable multi-repo mode + setMultiRepoMode(true) + onMultiRepoClick?.() + return + } + + // Disable multi-repo mode when selecting a specific owner + setMultiRepoMode(false) + onOwnerChange(value) onRepoChange('') // Reset repo when owner changes setRepoFilter('') // Reset filter when owner changes @@ -466,13 +483,22 @@ export function RepoSelector({ return (
- {showOwnersLoading ? (
Loading...
+ ) : multiRepoMode ? ( +
+ + Multi-repo +
) : size === 'sm' && selectedOwnerData ? ( // Mobile: Show only avatar
@@ -492,6 +518,14 @@ export function RepoSelector({ )} + {/* Multi-repo option */} + +
+ + Multi-repo +
+
+
{displayedOwners && displayedOwners.map((owner) => ( @@ -510,74 +544,84 @@ export function RepoSelector({ - {selectedOwner && ( - <> - / - - 50 - ? `Filter ${repos?.length || 0} repositories...` - : 'Filter repositories...' - } - value={repoFilter} - onChange={(e) => setRepoFilter(e.target.value)} - disabled={disabled} - className="text-base md:text-sm h-8" - onClick={(e) => e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - /> -
- )} - {filteredRepos.length === 0 && repoFilter ? ( -
- No repositories match "{repoFilter}" -
- ) : showReposLoading ? ( -
- - Loading repositories... -
- ) : ( - <> - {displayedRepos.map((repo) => ( - -
- {repo.name} - {repo.private && } + {/* Show "X repo(s) selected" button in multi-repo mode, or regular repo dropdown otherwise */} + {multiRepoMode ? ( + + ) : ( + selectedOwner && ( + <> + / + + 50 + ? `Filter ${repos?.length || 0} repositories...` + : 'Filter repositories...' + } + value={repoFilter} + onChange={(e) => setRepoFilter(e.target.value)} + disabled={disabled} + className="text-base md:text-sm h-8" + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + /> +
+ )} + {filteredRepos.length === 0 && repoFilter ? ( +
+ No repositories match "{repoFilter}" +
+ ) : showReposLoading ? ( +
+ + Loading repositories... +
+ ) : ( + <> + {displayedRepos.map((repo) => ( + +
+ {repo.name} + {repo.private && } +
+
+ ))} + {hasMoreRepos && ( +
+ Showing first 50 of {repos?.length || 0} repositories. Use filter to find more.
-
- ))} - {hasMoreRepos && ( -
- Showing first 50 of {repos?.length || 0} repositories. Use filter to find more. -
- )} - - )} -
- - + )} + + )} + + + + ) )}
) diff --git a/lib/atoms/multi-repo.ts b/lib/atoms/multi-repo.ts new file mode 100644 index 00000000..ef4fd99c --- /dev/null +++ b/lib/atoms/multi-repo.ts @@ -0,0 +1,14 @@ +import { atom } from 'jotai' + +export interface SelectedRepo { + owner: string + repo: string + full_name: string + clone_url: string +} + +// Whether multi-repo mode is enabled +export const multiRepoModeAtom = atom(false) + +// Selected repos in multi-repo mode +export const selectedReposAtom = atom([])