diff --git a/src/renderer/components/TaskModal.tsx b/src/renderer/components/TaskModal.tsx index 36d5a794d..c96296601 100644 --- a/src/renderer/components/TaskModal.tsx +++ b/src/renderer/components/TaskModal.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Check } from 'lucide-react'; import { Button } from './ui/button'; import { Spinner } from './ui/spinner'; import { @@ -16,6 +17,7 @@ import { TaskAdvancedSettings } from './TaskAdvancedSettings'; import { useIntegrationStatus } from './hooks/useIntegrationStatus'; import { type Agent } from '../types'; import { type AgentRun } from '../types/chat'; +import { type Project } from '../types/app'; import { agentMeta } from '../providers/meta'; import { isValidProviderId } from '@shared/providers/registry'; import { type LinearIssueSummary } from '../types/linear'; @@ -33,6 +35,7 @@ import { generateTaskNameFromContext } from '../lib/branchNameGenerator'; import { useProjectManagementContext } from '../contexts/ProjectManagementProvider'; import { useTaskManagementContext } from '../contexts/TaskManagementContext'; import { rpc } from '@/lib/rpc'; +import { cn } from '../lib/utils'; const DEFAULT_AGENT: Agent = 'claude'; @@ -63,7 +66,8 @@ interface TaskModalProps { autoApprove?: boolean, useWorktree?: boolean, baseRef?: string, - nameGenerated?: boolean + nameGenerated?: boolean, + project?: Project | null ) => Promise; } @@ -86,7 +90,8 @@ export function TaskModalOverlay({ onClose }: TaskModalOverlayProps) { autoApprove, useWorktree, baseRef, - nameGenerated + nameGenerated, + project ) => { await handleCreateTask( name, @@ -99,7 +104,8 @@ export function TaskModalOverlay({ onClose }: TaskModalOverlayProps) { autoApprove, useWorktree, baseRef, - nameGenerated + nameGenerated, + project ); }} /> @@ -108,17 +114,39 @@ export function TaskModalOverlay({ onClose }: TaskModalOverlayProps) { const TaskModal: React.FC = ({ onClose, onCreateTask }) => { const { + projects, selectedProject, - projectDefaultBranch: defaultBranch, - projectBranchOptions: branchOptions, - isLoadingBranches, - refreshBranches, + projectDefaultBranch: contextDefaultBranch, + projectBranchOptions: contextBranchOptions, + isLoadingBranches: contextIsLoadingBranches, } = useProjectManagementContext(); - const { linkedGithubIssueMap } = useTaskManagementContext(); + const { linkedGithubIssueMap, tasksByProjectId } = useTaskManagementContext(); - const projectName = selectedProject?.name || ''; - const existingNames = (selectedProject?.tasks || []).map((w) => w.name); - const projectPath = selectedProject?.path; + // Local project selection - defaults to current project + const [selectedModalProject, setSelectedModalProject] = useState(selectedProject); + + // Local branch state for when modal project differs from context project + const [localBranchOptions, setLocalBranchOptions] = useState<{ value: string; label: string }[]>( + [] + ); + const [localIsLoadingBranches, setLocalIsLoadingBranches] = useState(false); + + // Use context branch data when modal project matches selected project, otherwise use local state + const isUsingContextProject = selectedModalProject?.id === selectedProject?.id; + const branchOptions = isUsingContextProject ? contextBranchOptions : localBranchOptions; + const isLoadingBranches = isUsingContextProject + ? contextIsLoadingBranches + : localIsLoadingBranches; + const defaultBranch = selectedModalProject?.gitInfo?.baseRef || 'main'; + + // Derived values use local selection + const projectName = selectedModalProject?.name || ''; + const existingNames = useMemo(() => { + if (!selectedModalProject) return []; + const tasks = tasksByProjectId[selectedModalProject.id] || []; + return tasks.map((t) => t.name); + }, [selectedModalProject, tasksByProjectId]); + const projectPath = selectedModalProject?.path; // Form state const [taskName, setTaskName] = useState(''); const [agentRuns, setAgentRuns] = useState([{ agent: DEFAULT_AGENT, runs: 1 }]); @@ -138,15 +166,17 @@ const TaskModal: React.FC = ({ onClose, onCreateTask }) => { const [useWorktree, setUseWorktree] = useState(true); // Branch selection state - sync with defaultBranch unless user manually changed it - const [selectedBranch, setSelectedBranch] = useState(defaultBranch); + const [selectedBranch, setSelectedBranch] = useState(contextDefaultBranch); const userChangedBranchRef = useRef(false); const taskNameInputRef = useRef(null); + // Track current modal project for race condition handling in branch loading + const currentModalProjectIdRef = useRef(null); useEffect(() => { if (!userChangedBranchRef.current) { - setSelectedBranch(defaultBranch); + setSelectedBranch(isUsingContextProject ? contextDefaultBranch : defaultBranch); } - }, [defaultBranch]); + }, [contextDefaultBranch, defaultBranch, isUsingContextProject]); const handleBranchChange = (value: string) => { setSelectedBranch(value); @@ -209,7 +239,6 @@ const TaskModal: React.FC = ({ onClose, onCreateTask }) => { // Reset form and load settings on mount useEffect(() => { - void refreshBranches(); // Reset state setTaskName(''); setAutoGeneratedName(''); @@ -311,6 +340,110 @@ const TaskModal: React.FC = ({ onClose, onCreateTask }) => { validate, ]); + // Refresh branches when modal project changes (only for non-context projects) + useEffect(() => { + if (!selectedModalProject) return; + + // Track current project for race condition handling + currentModalProjectIdRef.current = selectedModalProject.id; + + // If we're on the context project, the context already handles branch loading + if (selectedModalProject.id === selectedProject?.id) { + userChangedBranchRef.current = false; + setSelectedBranch(contextDefaultBranch); + return; + } + + // Load branches for the different project + const loadBranches = async () => { + const projectIdAtStart = selectedModalProject.id; + setLocalIsLoadingBranches(true); + const initialBranch = selectedModalProject.gitInfo?.baseRef || 'main'; + setLocalBranchOptions([{ value: initialBranch, label: initialBranch }]); + + try { + let options: { value: string; label: string }[]; + + if (selectedModalProject.isRemote && selectedModalProject.sshConnectionId) { + const result = await window.electronAPI.sshExecuteCommand( + selectedModalProject.sshConnectionId, + 'git branch -a --format="%(refname:short)"', + selectedModalProject.path + ); + if (result.exitCode === 0 && result.stdout) { + const branches = result.stdout + .split('\n') + .map((b) => b.trim()) + .filter((b) => b.length > 0 && !b.includes('HEAD')); + options = branches.map((b) => ({ value: b, label: b })); + } else { + options = []; + } + } else { + const res = await window.electronAPI.listRemoteBranches({ + projectPath: selectedModalProject.path, + }); + if (res.success && res.branches) { + options = res.branches.map((b) => ({ + value: b.ref, + label: b.remote ? b.label : `${b.branch} (local)`, + })); + } else { + options = []; + } + } + + // Skip update if project changed during async operation + if (currentModalProjectIdRef.current !== projectIdAtStart) return; + + if (options.length > 0) { + setLocalBranchOptions(options); + } + } catch (error) { + console.error('Failed to load branches:', error); + } finally { + // Only clear loading if still on same project + if (currentModalProjectIdRef.current === projectIdAtStart) { + setLocalIsLoadingBranches(false); + } + } + }; + + userChangedBranchRef.current = false; + setSelectedBranch(selectedModalProject.gitInfo?.baseRef || 'main'); + void loadBranches(); + }, [selectedModalProject?.id, selectedProject?.id, contextDefaultBranch]); + + // Handle number key shortcuts for project selection + useEffect(() => { + if (projects.length <= 1) return; + + const handleKeyDown = (event: KeyboardEvent) => { + // Skip if typing in input/textarea + const target = event.target as HTMLElement; + if ( + target?.tagName === 'INPUT' || + target?.tagName === 'TEXTAREA' || + target?.isContentEditable + ) { + return; + } + + // Check for number keys 1-9 without modifiers + if (!/^[1-9]$/.test(event.key)) return; + if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return; + + const index = parseInt(event.key, 10) - 1; + if (index < projects.length) { + event.preventDefault(); + setSelectedModalProject(projects[index]); + } + }; + + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [projects]); + const handleNameChange = (val: string) => { setTaskName(val); setError(validate(val)); @@ -372,7 +505,8 @@ const TaskModal: React.FC = ({ onClose, onCreateTask }) => { hasAutoApproveSupport ? autoApprove : false, useWorktree, selectedBranch, - isNameGenerated + isNameGenerated, + selectedModalProject ); onClose(); } catch (error) { @@ -402,25 +536,70 @@ const TaskModal: React.FC = ({ onClose, onCreateTask }) => { Create a task and open the agent workspace. -
-

{projectName}

-
- from - {branchOptions.length > 0 ? ( - - ) : ( - - {isLoadingBranches ? 'Loading...' : selectedBranch || defaultBranch} - - )} + {projects.length > 1 ? ( +
+
+ {projects.map((project, index) => ( + + ))} +
+
+ from + {branchOptions.length > 0 ? ( + + ) : ( + + {isLoadingBranches ? 'Loading...' : selectedBranch || defaultBranch} + + )} +
-
+ ) : ( +
+

{projectName}

+
+ from + {branchOptions.length > 0 ? ( + + ) : ( + + {isLoadingBranches ? 'Loading...' : selectedBranch || defaultBranch} + + )} +
+
+ )}
diff --git a/src/renderer/hooks/useTaskManagement.ts b/src/renderer/hooks/useTaskManagement.ts index ba0287125..712be6260 100644 --- a/src/renderer/hooks/useTaskManagement.ts +++ b/src/renderer/hooks/useTaskManagement.ts @@ -915,11 +915,18 @@ export function useTaskManagement() { autoApprove?: boolean, useWorktree: boolean = true, baseRef?: string, - nameGenerated?: boolean + nameGenerated?: boolean, + project?: Project | null ) => { - const targetProject = pendingTaskProjectRef.current || selectedProject; + const targetProject = project || pendingTaskProjectRef.current || selectedProject; pendingTaskProjectRef.current = null; if (!targetProject) return; + + // Navigate to target project if different from current + if (targetProject.id !== selectedProject?.id) { + setSelectedProject(targetProject); + } + setIsCreatingTask(true); await createTaskMutation.mutateAsync({ project: targetProject, @@ -936,7 +943,7 @@ export function useTaskManagement() { baseRef, }); }, - [selectedProject, createTaskMutation] + [selectedProject, setSelectedProject, createTaskMutation] ); const handleTaskInterfaceReady = useCallback(() => {