-
Notifications
You must be signed in to change notification settings - Fork 236
Autoselect owner/repo on homepage via query params #200
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { cookies } from 'next/headers' | ||
| import { ChatPageContent } from '@/components/chat-page-content' | ||
| import { getServerSession } from '@/lib/session/get-server-session' | ||
| import { getGitHubStars } from '@/lib/github-stars' | ||
| import { getMaxSandboxDuration } from '@/lib/db/settings' | ||
|
|
||
| interface ChatPageProps { | ||
| params: Promise<{ | ||
| owner: string | ||
| repo: string | ||
| }> | ||
| } | ||
|
|
||
| export default async function ChatPage({ params }: ChatPageProps) { | ||
| const { owner, repo } = await params | ||
| const cookieStore = await cookies() | ||
| const installDependencies = cookieStore.get('install-dependencies')?.value === 'true' | ||
| const keepAlive = cookieStore.get('keep-alive')?.value === 'true' | ||
|
|
||
| const session = await getServerSession() | ||
|
|
||
| // Get max sandbox duration for this user (user-specific > global > env var) | ||
| const maxSandboxDuration = await getMaxSandboxDuration(session?.user?.id) | ||
| const maxDuration = parseInt(cookieStore.get('max-duration')?.value || maxSandboxDuration.toString(), 10) | ||
|
|
||
| const stars = await getGitHubStars() | ||
|
|
||
| return ( | ||
| <ChatPageContent | ||
| owner={owner} | ||
| repo={repo} | ||
| initialInstallDependencies={installDependencies} | ||
| initialMaxDuration={maxDuration} | ||
| initialKeepAlive={keepAlive} | ||
| maxSandboxDuration={maxSandboxDuration} | ||
| user={session?.user ?? null} | ||
| initialStars={stars} | ||
| /> | ||
| ) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,353 @@ | ||
| 'use client' | ||
|
|
||
| import { useState, useEffect } from 'react' | ||
| import { TaskForm } from '@/components/task-form' | ||
| import { HomePageHeader } from '@/components/home-page-header' | ||
| import { toast } from 'sonner' | ||
| import { useRouter } from 'next/navigation' | ||
| import { useTasks } from '@/components/app-layout' | ||
| import { setSelectedOwner, setSelectedRepo } from '@/lib/utils/cookies' | ||
| import type { Session } from '@/lib/session/types' | ||
| import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' | ||
| 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 { taskPromptAtom } from '@/lib/atoms/task' | ||
| import { HomePageMobileFooter } from '@/components/home-page-mobile-footer' | ||
|
|
||
| interface ChatPageContentProps { | ||
| owner: string | ||
| repo: string | ||
| initialInstallDependencies?: boolean | ||
| initialMaxDuration?: number | ||
| initialKeepAlive?: boolean | ||
| maxSandboxDuration?: number | ||
| user?: Session['user'] | null | ||
| initialStars?: number | ||
| } | ||
|
|
||
| export function ChatPageContent({ | ||
| owner, | ||
| repo, | ||
| initialInstallDependencies = false, | ||
| initialMaxDuration = 300, | ||
| initialKeepAlive = false, | ||
| maxSandboxDuration = 300, | ||
| user = null, | ||
| initialStars = 1200, | ||
| }: ChatPageContentProps) { | ||
| const [isSubmitting, setIsSubmitting] = useState(false) | ||
| const [selectedOwner, setSelectedOwnerState] = useState(owner) | ||
| const [selectedRepo, setSelectedRepoState] = useState(repo) | ||
| const [showSignInDialog, setShowSignInDialog] = useState(false) | ||
| const [loadingVercel, setLoadingVercel] = useState(false) | ||
| const [loadingGitHub, setLoadingGitHub] = useState(false) | ||
| const router = useRouter() | ||
| const { refreshTasks, addTaskOptimistically } = useTasks() | ||
| const setTaskPrompt = useSetAtom(taskPromptAtom) | ||
|
|
||
| // Check which auth providers are enabled | ||
| const { github: hasGitHub, vercel: hasVercel } = getEnabledAuthProviders() | ||
|
|
||
| // Set cookies for the selected owner/repo on mount | ||
| useEffect(() => { | ||
| setSelectedOwner(owner) | ||
| setSelectedRepo(repo) | ||
| }, [owner, repo]) | ||
|
|
||
| // Wrapper functions to update both state and cookies | ||
| const handleOwnerChange = (newOwner: string) => { | ||
| setSelectedOwnerState(newOwner) | ||
| setSelectedOwner(newOwner) | ||
| // Navigate to new URL if owner changes | ||
| if (selectedRepo) { | ||
| router.push(`/chat/${newOwner}/${selectedRepo}`) | ||
| } | ||
| } | ||
|
|
||
| const handleRepoChange = (newRepo: string) => { | ||
| setSelectedRepoState(newRepo) | ||
| setSelectedRepo(newRepo) | ||
| // Navigate to new URL if repo changes | ||
| router.push(`/chat/${selectedOwner}/${newRepo}`) | ||
| } | ||
|
|
||
| const handleTaskSubmit = async (data: { | ||
| prompt: string | ||
| repoUrl: string | ||
| selectedAgent: string | ||
| selectedModel: string | ||
| selectedModels?: string[] | ||
| installDependencies: boolean | ||
| maxDuration: number | ||
| keepAlive: boolean | ||
| }) => { | ||
| // Check if user is authenticated | ||
| if (!user) { | ||
| setShowSignInDialog(true) | ||
| 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 | ||
| } | ||
|
|
||
| // Clear the saved prompt since we're actually submitting it now | ||
| setTaskPrompt('') | ||
|
|
||
| setIsSubmitting(true) | ||
|
|
||
| // Check if this is multi-agent mode with multiple models selected | ||
| const isMultiAgent = data.selectedAgent === 'multi-agent' && data.selectedModels && data.selectedModels.length > 0 | ||
|
|
||
| if (isMultiAgent) { | ||
| // Create multiple tasks, one for each selected model | ||
| const taskIds: string[] = [] | ||
| const tasksData = data.selectedModels!.map((modelValue) => { | ||
| // Parse agent:model format | ||
| const [agent, model] = modelValue.split(':') | ||
| const { id } = addTaskOptimistically({ | ||
| prompt: data.prompt, | ||
| repoUrl: data.repoUrl, | ||
| selectedAgent: agent, | ||
| selectedModel: model, | ||
| installDependencies: data.installDependencies, | ||
| maxDuration: data.maxDuration, | ||
| }) | ||
| taskIds.push(id) | ||
| return { | ||
| id, | ||
| prompt: data.prompt, | ||
| repoUrl: data.repoUrl, | ||
| selectedAgent: agent, | ||
| selectedModel: model, | ||
| 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') | ||
| } | ||
|
|
||
| // 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) | ||
| } | ||
| } else { | ||
| // Single task creation (original behavior) | ||
| const { id } = addTaskOptimistically(data) | ||
|
|
||
| // Navigate to the new task page immediately | ||
| router.push(`/tasks/${id}`) | ||
|
|
||
| try { | ||
| const response = await fetch('/api/tasks', { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| body: JSON.stringify({ ...data, id }), // Include the pre-generated ID | ||
| }) | ||
|
|
||
| if (response.ok) { | ||
| toast.success('Task created successfully!') | ||
| // Refresh sidebar to get the real task data from server | ||
| await refreshTasks() | ||
| } else { | ||
| const error = await response.json() | ||
| // Show detailed message for rate limits, or generic error message | ||
| toast.error(error.message || error.error || 'Failed to create task') | ||
| // TODO: Remove the optimistic task on error | ||
| await refreshTasks() // For now, just refresh to remove the optimistic task | ||
| } | ||
| } catch (error) { | ||
| console.error('Error creating task:', error) | ||
| toast.error('Failed to create task') | ||
| // TODO: Remove the optimistic task on error | ||
| await refreshTasks() // For now, just refresh to remove the optimistic task | ||
| } finally { | ||
| setIsSubmitting(false) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const handleVercelSignIn = async () => { | ||
| setLoadingVercel(true) | ||
| await redirectToSignIn() | ||
| } | ||
|
|
||
| const handleGitHubSignIn = () => { | ||
| setLoadingGitHub(true) | ||
| window.location.href = '/api/auth/signin/github' | ||
| } | ||
|
|
||
| return ( | ||
| <div className="flex-1 bg-background flex flex-col"> | ||
| <div className="p-3"> | ||
| <HomePageHeader | ||
| selectedOwner={selectedOwner} | ||
| selectedRepo={selectedRepo} | ||
| onOwnerChange={handleOwnerChange} | ||
| onRepoChange={handleRepoChange} | ||
| user={user} | ||
| initialStars={initialStars} | ||
| /> | ||
| </div> | ||
|
|
||
| <div className="flex-1 flex items-center justify-center px-4 pb-20 md:pb-4"> | ||
| <TaskForm | ||
| onSubmit={handleTaskSubmit} | ||
| isSubmitting={isSubmitting} | ||
| selectedOwner={selectedOwner} | ||
| selectedRepo={selectedRepo} | ||
| initialInstallDependencies={initialInstallDependencies} | ||
| initialMaxDuration={initialMaxDuration} | ||
| initialKeepAlive={initialKeepAlive} | ||
| maxSandboxDuration={maxSandboxDuration} | ||
| /> | ||
| </div> | ||
|
|
||
| {/* Mobile Footer with Stars and Deploy Button */} | ||
| <HomePageMobileFooter initialStars={initialStars} /> | ||
|
|
||
| {/* Sign In Dialog */} | ||
| <Dialog open={showSignInDialog} onOpenChange={setShowSignInDialog}> | ||
| <DialogContent className="sm:max-w-md"> | ||
| <DialogHeader> | ||
| <DialogTitle>Sign in to continue</DialogTitle> | ||
| <DialogDescription> | ||
| {hasGitHub && hasVercel | ||
| ? 'You need to sign in to create tasks. Choose how you want to sign in.' | ||
| : hasVercel | ||
| ? 'You need to sign in with Vercel to create tasks.' | ||
| : 'You need to sign in with GitHub to create tasks.'} | ||
| </DialogDescription> | ||
| </DialogHeader> | ||
|
|
||
| <div className="flex flex-col gap-3 py-4"> | ||
| {hasVercel && ( | ||
| <Button | ||
| onClick={handleVercelSignIn} | ||
| disabled={loadingVercel || loadingGitHub} | ||
| variant="outline" | ||
| size="lg" | ||
| className="w-full" | ||
| > | ||
| {loadingVercel ? ( | ||
| <> | ||
| <svg | ||
| className="animate-spin -ml-1 mr-2 h-4 w-4" | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| fill="none" | ||
| viewBox="0 0 24 24" | ||
| > | ||
| <circle | ||
| className="opacity-25" | ||
| cx="12" | ||
| cy="12" | ||
| r="10" | ||
| stroke="currentColor" | ||
| strokeWidth="4" | ||
| ></circle> | ||
| <path | ||
| className="opacity-75" | ||
| fill="currentColor" | ||
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" | ||
| ></path> | ||
| </svg> | ||
| Loading... | ||
| </> | ||
| ) : ( | ||
| <> | ||
| <svg viewBox="0 0 76 65" className="h-3 w-3 mr-2" fill="currentColor"> | ||
| <path d="M37.5274 0L75.0548 65H0L37.5274 0Z" /> | ||
| </svg> | ||
| Sign in with Vercel | ||
| </> | ||
| )} | ||
| </Button> | ||
| )} | ||
|
|
||
| {hasGitHub && ( | ||
| <Button | ||
| onClick={handleGitHubSignIn} | ||
| disabled={loadingVercel || loadingGitHub} | ||
| variant="outline" | ||
| size="lg" | ||
| className="w-full" | ||
| > | ||
| {loadingGitHub ? ( | ||
| <> | ||
| <svg | ||
| className="animate-spin -ml-1 mr-2 h-4 w-4" | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| fill="none" | ||
| viewBox="0 0 24 24" | ||
| > | ||
| <circle | ||
| className="opacity-25" | ||
| cx="12" | ||
| cy="12" | ||
| r="10" | ||
| stroke="currentColor" | ||
| strokeWidth="4" | ||
| ></circle> | ||
| <path | ||
| className="opacity-75" | ||
| fill="currentColor" | ||
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" | ||
| ></path> | ||
| </svg> | ||
| Loading... | ||
| </> | ||
| ) : ( | ||
| <> | ||
| <GitHubIcon className="h-4 w-4 mr-2" /> | ||
| Sign in with GitHub | ||
| </> | ||
| )} | ||
| </Button> | ||
| )} | ||
| </div> | ||
| </DialogContent> | ||
| </Dialog> | ||
| </div> | ||
| ) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
parseIntcall without NaN validation can produceNaNif the cookie contains an invalid value, which will cause UI issues in the form component whereNaN.toString()won't match any valid select options.View Details
Analysis
NaN value from unvalidated parseInt on invalid cookie breaks Select component in task form
What fails:
ChatPageserver component inapp/chat/[owner]/[repo]/page.tsxat line 24 fails to validate the result ofparseInt()when parsing themax-durationcookie. If the cookie contains any non-empty invalid string (e.g.,"abc","xyz"),parseIntreturnsNaN. ThisNaNvalue is then passed toChatPageContentasinitialMaxDurationand used in the task form's Select component atcomponents/task-form.tsxline 685, whereNaN.toString()produces the string"NaN"which doesn't match any valid SelectItem value, causing the form to display without a selected value.How to reproduce:
max-duration=abc/chat/owner/repo)Result: The Maximum Duration select field shows no selected value (empty placeholder instead of a valid duration like "5 minutes"). The field value is set to the invalid string
"NaN"which doesn't match any option.Expected: The Maximum Duration select should always display a valid value. According to the established pattern in
lib/utils/cookies.tslines 206-209 (getMaxDuration()function), invalid cookie values should fall back to the validmaxSandboxDurationvalue, ensuring the select always has a matching option.Root cause: The code uses
parseInt(cookieStore.get('max-duration')?.value || maxSandboxDuration.toString(), 10)which only applies the fallback to empty strings. Non-empty invalid strings like"abc"are parsed byparseInt, producingNaNwithout any validation. The fix applies the same validation pattern used throughout the codebase: parse the value withparseInt(), checkisNaN(), and fall back to the default if the result is invalid.