Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 91 additions & 7 deletions components/home-page-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -132,19 +137,98 @@ 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
setTaskPrompt('')

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
Comment on lines +163 to +229
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-repo mode is not exited after task submission, leaving the UI showing "Multi-repo" with "0 repos selected" in an inconsistent state.

View Details
📝 Patch Details
diff --git a/components/home-page-content.tsx b/components/home-page-content.tsx
index 72843b2..f3e7e41 100644
--- a/components/home-page-content.tsx
+++ b/components/home-page-content.tsx
@@ -13,7 +13,7 @@ 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, useAtom, useAtomValue } from 'jotai'
+import { useSetAtom, useAtom } from 'jotai'
 import { taskPromptAtom } from '@/lib/atoms/task'
 import { HomePageMobileFooter } from '@/components/home-page-mobile-footer'
 import { multiRepoModeAtom, selectedReposAtom } from '@/lib/atoms/multi-repo'
@@ -51,7 +51,7 @@ export function HomePageContent({
   const setTaskPrompt = useSetAtom(taskPromptAtom)
 
   // Multi-repo mode state
-  const multiRepoMode = useAtomValue(multiRepoModeAtom)
+  const [multiRepoMode, setMultiRepoMode] = useAtom(multiRepoModeAtom)
   const [selectedRepos, setSelectedRepos] = useAtom(selectedReposAtom)
 
   // Check which auth providers are enabled
@@ -214,8 +214,9 @@ export function HomePageContent({
           toast.error('Failed to create tasks')
         }
 
-        // Clear selected repos after creating tasks
+        // Clear selected repos and exit multi-repo mode after creating tasks
         setSelectedRepos([])
+        setMultiRepoMode(false)
 
         // Refresh sidebar to get the real task data from server
         await refreshTasks()

Analysis

Multi-repo mode not exited after task submission, leaving UI in inconsistent state

What fails: After successfully submitting multi-repo tasks in HomePageContent.handleTaskSubmit(), the multiRepoMode atom remains true while selectedRepos is cleared to []. This leaves the UI showing "Multi-repo" header but displaying "0 repos selected", which is inconsistent and confusing to users.

How to reproduce:

  1. On the home page, click on the owner selector dropdown
  2. Select "Multi-repo" option
  3. Click the "0 repos selected" button to open the multi-repo dialog
  4. Select 2+ repositories from the dialog and click "Done"
  5. Submit a task with the selected repositories
  6. Navigate back to the home page (or check atom state after task submission completes)

Result: The UI displays "Multi-repo" with "0 repos selected" - an inconsistent state. The multiRepoMode atom is true but selectedRepos is empty.

Expected behavior: After multi-repo task submission completes, the multiRepoMode should be reset to false along with clearing selectedRepos, returning the UI to its normal owner/repo selector state.

Root cause: In components/home-page-content.tsx line 218, setSelectedRepos([]) is called after task creation, but setMultiRepoMode(false) is never called. This leaves the state inconsistent.

Fix location: components/home-page-content.tsx lines 216-220

  • Changed multiRepoMode from read-only (useAtomValue) to read-write (useAtom)
  • Added setMultiRepoMode(false) call immediately after setSelectedRepos([]) to maintain state consistency

}

// Check if this is multi-agent mode with multiple models selected
const isMultiAgent = data.selectedAgent === 'multi-agent' && data.selectedModels && data.selectedModels.length > 0

Expand Down
4 changes: 4 additions & 0 deletions components/home-page-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -249,6 +251,7 @@ export function HomePageHeader({
onOwnerChange={onOwnerChange}
onRepoChange={onRepoChange}
size="sm"
onMultiRepoClick={() => setShowMultiRepoDialog(true)}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
Expand Down Expand Up @@ -315,6 +318,7 @@ export function HomePageHeader({
leftActions={leftActions}
/>
<OpenRepoUrlDialog open={showOpenRepoDialog} onOpenChange={setShowOpenRepoDialog} onSubmit={handleOpenRepoUrl} />
<MultiRepoDialog open={showMultiRepoDialog} onOpenChange={setShowMultiRepoDialog} />
</>
)
}
229 changes: 229 additions & 0 deletions components/multi-repo-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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<RepoWithOwner[]>([])
const [loadingRepos, setLoadingRepos] = useState(false)
const [showDropdown, setShowDropdown] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(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 }))
}
Comment on lines +50 to +53
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (response.ok) {
const repos: GitHubRepo[] = await response.json()
return repos.map((repo) => ({ ...repo, owner: owner.login }))
}
if (!response.ok) {
console.error(`Failed to load repos for owner ${owner.login}: HTTP ${response.status}`)
return []
}
const repos: GitHubRepo[] = await response.json()
return repos.map((repo) => ({ ...repo, owner: owner.login }))

HTTP error responses when fetching repos are silently ignored with no error logging or user notification, resulting in incomplete repo lists without user awareness.

View Details

Analysis

Missing error logging for failed repo fetches in MultiRepoDialog

What fails: MultiRepoDialog silently ignores HTTP error responses when fetching repositories for multiple owners. When /api/github/repos?owner=X returns a non-ok status (401, 403, 500, etc.), no error is logged to console and the user receives no notification, resulting in incomplete repository lists without any indication of failure.

How to reproduce:

  1. Open MultiRepoDialog with multiple GitHub owners configured
  2. Configure API endpoint to return 403 Forbidden for at least one owner's repos
  3. Open the dialog and trigger the repo loading effect

Result: When an owner's repo fetch fails with HTTP error status:

  • Dialog shows fewer repos than expected (gaps in list)
  • No console error is logged (unlike network exceptions which are logged)
  • User sees no indication that some repos failed to load
  • Inconsistent with other similar components (repo-selector.tsx, repo-commits.tsx, file-editor.tsx)

Expected: Non-ok HTTP responses should be logged to console with owner and status code for debugging, matching the pattern established elsewhere in the codebase.

Fix: Added error logging when response.ok is false, logging the owner name and HTTP status code to help developers identify which owners' repos failed to load.

} 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Select Repositories</DialogTitle>
<DialogDescription>
Choose multiple repositories to create tasks for. A separate task will be created for each selected
repository.
</DialogDescription>
</DialogHeader>

<div className="space-y-4 py-4">
{/* Selected repos - shown above search so dropdown doesn't cover them */}
{selectedRepos.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Selected ({selectedRepos.length}):</span>
<button
onClick={() => setSelectedRepos([])}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear all
</button>
</div>
<div className="flex flex-wrap gap-2">
{selectedRepos.map((repo) => (
<Badge key={repo.full_name} variant="secondary" className="gap-1 pr-1">
<span>{repo.full_name}</span>
<button
onClick={() => handleRemoveRepo(repo.full_name)}
className="ml-1 rounded-full hover:bg-muted p-0.5"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
</div>
)}

{/* Search input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={inputRef}
placeholder="Search all repositories..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
setShowDropdown(true)
}}
onFocus={() => setShowDropdown(true)}
className="pl-9"
/>

{/* Dropdown */}
{showDropdown && (
<div
ref={dropdownRef}
className="absolute top-full left-0 right-0 mt-1 bg-popover border rounded-md shadow-lg max-h-60 overflow-y-auto z-50"
>
{loadingRepos ? (
<div className="p-4 flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Loading repositories...</span>
</div>
) : filteredRepos.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground text-center">
{searchQuery ? `No repositories match "${searchQuery}"` : 'No repositories found'}
</div>
) : (
filteredRepos.slice(0, 50).map((repo) => (
<button
key={repo.full_name}
onClick={() => handleSelectRepo(repo)}
className="w-full px-3 py-2 text-left flex items-center gap-2 transition-colors hover:bg-accent"
>
<span className="font-medium">{repo.full_name}</span>
{repo.private && <Lock className="h-3 w-3 text-muted-foreground" />}
</button>
))
)}
{filteredRepos.length > 50 && (
<div className="p-2 text-xs text-muted-foreground text-center border-t">
Showing first 50 of {filteredRepos.length} repositories. Use search to find more.
</div>
)}
</div>
)}
</div>
</div>

<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => onOpenChange(false)} disabled={selectedRepos.length === 0}>
Done ({selectedRepos.length} selected)
</Button>
</div>
</DialogContent>
</Dialog>
)
}
Loading
Loading