From 5657f803326e894d8711abc4ab275e7a0ab0679b Mon Sep 17 00:00:00 2001 From: Dan Rocha Date: Tue, 10 Mar 2026 10:25:27 -0600 Subject: [PATCH 1/2] feat(task-modal): add project selection via number keys Add the ability to select a project using number keys (1-9) when the TaskModal is open via Cmd+N. Previously, tasks were always created in the currently active project with no way to change it from the modal. Changes: - Add inline project selector to TaskModal header showing all projects with number badges (1-9) - Allow number key selection when no input field is focused - Pre-select the current project by default - Update branch options when project changes - Pass selected project to handleCreateTask Closes #1376 Co-Authored-By: Claude Opus 4.5 --- src/renderer/components/TaskModal.tsx | 234 ++++++++++++++++++++---- src/renderer/hooks/useTaskManagement.ts | 5 +- 2 files changed, 203 insertions(+), 36 deletions(-) diff --git a/src/renderer/components/TaskModal.tsx b/src/renderer/components/TaskModal.tsx index 36d5a794d..798e19d6a 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,15 @@ 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); useEffect(() => { if (!userChangedBranchRef.current) { - setSelectedBranch(defaultBranch); + setSelectedBranch(isUsingContextProject ? contextDefaultBranch : defaultBranch); } - }, [defaultBranch]); + }, [contextDefaultBranch, defaultBranch, isUsingContextProject]); const handleBranchChange = (value: string) => { setSelectedBranch(value); @@ -209,7 +237,6 @@ const TaskModal: React.FC = ({ onClose, onCreateTask }) => { // Reset form and load settings on mount useEffect(() => { - void refreshBranches(); // Reset state setTaskName(''); setAutoGeneratedName(''); @@ -311,6 +338,99 @@ const TaskModal: React.FC = ({ onClose, onCreateTask }) => { validate, ]); + // Refresh branches when modal project changes (only for non-context projects) + useEffect(() => { + if (!selectedModalProject) return; + // 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 () => { + 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 = []; + } + } + + if (options.length > 0) { + setLocalBranchOptions(options); + } + } catch (error) { + console.error('Failed to load branches:', error); + } finally { + 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 +492,8 @@ const TaskModal: React.FC = ({ onClose, onCreateTask }) => { hasAutoApproveSupport ? autoApprove : false, useWorktree, selectedBranch, - isNameGenerated + isNameGenerated, + selectedModalProject ); onClose(); } catch (error) { @@ -402,25 +523,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..a9bda628b 100644 --- a/src/renderer/hooks/useTaskManagement.ts +++ b/src/renderer/hooks/useTaskManagement.ts @@ -915,9 +915,10 @@ 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; setIsCreatingTask(true); From ebe22f17d38c47ab48d3def142f7542b51b84ed4 Mon Sep 17 00:00:00 2001 From: Dan Rocha Date: Tue, 10 Mar 2026 13:27:32 -0600 Subject: [PATCH 2/2] fix(task-modal): address PR review comments for project selection - Fix race condition in branch loading when rapidly switching projects by tracking current project ID in a ref and skipping stale updates - Fix missing project navigation when creating task for non-active project by calling setSelectedProject when targetProject differs from current Co-Authored-By: Claude Opus 4.5 --- src/renderer/components/TaskModal.tsx | 15 ++++++++++++++- src/renderer/hooks/useTaskManagement.ts | 8 +++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/TaskModal.tsx b/src/renderer/components/TaskModal.tsx index 798e19d6a..c96296601 100644 --- a/src/renderer/components/TaskModal.tsx +++ b/src/renderer/components/TaskModal.tsx @@ -169,6 +169,8 @@ const TaskModal: React.FC = ({ onClose, onCreateTask }) => { 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) { @@ -341,6 +343,10 @@ const TaskModal: React.FC = ({ onClose, onCreateTask }) => { // 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; @@ -350,6 +356,7 @@ const TaskModal: React.FC = ({ onClose, onCreateTask }) => { // 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 }]); @@ -386,13 +393,19 @@ const TaskModal: React.FC = ({ onClose, onCreateTask }) => { } } + // 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 { - setLocalIsLoadingBranches(false); + // Only clear loading if still on same project + if (currentModalProjectIdRef.current === projectIdAtStart) { + setLocalIsLoadingBranches(false); + } } }; diff --git a/src/renderer/hooks/useTaskManagement.ts b/src/renderer/hooks/useTaskManagement.ts index a9bda628b..712be6260 100644 --- a/src/renderer/hooks/useTaskManagement.ts +++ b/src/renderer/hooks/useTaskManagement.ts @@ -921,6 +921,12 @@ export function useTaskManagement() { 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, @@ -937,7 +943,7 @@ export function useTaskManagement() { baseRef, }); }, - [selectedProject, createTaskMutation] + [selectedProject, setSelectedProject, createTaskMutation] ); const handleTaskInterfaceReady = useCallback(() => {