diff --git a/apps/emdash-desktop/src/main/core/automations/actions/taskCreate.test.ts b/apps/emdash-desktop/src/main/core/automations/actions/taskCreate.test.ts index 2b36e3f5f6..14b8289b09 100644 --- a/apps/emdash-desktop/src/main/core/automations/actions/taskCreate.test.ts +++ b/apps/emdash-desktop/src/main/core/automations/actions/taskCreate.test.ts @@ -77,7 +77,7 @@ const automation: Automation = { triggerConfig: { expr: '0 9 * * *', tz: 'UTC' }, conversationConfig: { prompt: 'Check things', provider: 'claude', autoApprove: false }, taskConfig: { - version: '1' as const, + version: '2' as const, taskConfig: { version: '1', name: 'Stored task', @@ -281,6 +281,27 @@ describe('executeTaskCreate', () => { ); }); + it('uses the stored branch name override when present', async () => { + vi.mocked(generateRandom).mockReturnValue('jolly-tiger-runs-fast'); + + await executeTaskCreate( + { + ...automation, + taskConfig: { ...automation.taskConfig!, branchNameOverride: 'stored-task-branch' }, + }, + run, + noopStep + ); + + expect(prepareCreateTask).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceConfig: expect.objectContaining({ + git: expect.objectContaining({ branchName: 'stored-task-branch' }), + }), + }) + ); + }); + it('enables auto-approval for Cursor conversations', async () => { await executeTaskCreate( { diff --git a/apps/emdash-desktop/src/main/core/automations/actions/taskCreate.ts b/apps/emdash-desktop/src/main/core/automations/actions/taskCreate.ts index 598057a9db..87ef6a3901 100644 --- a/apps/emdash-desktop/src/main/core/automations/actions/taskCreate.ts +++ b/apps/emdash-desktop/src/main/core/automations/actions/taskCreate.ts @@ -18,6 +18,7 @@ import { resolveAutomationAgentAutoApprove } from '@shared/core/agents/agent-aut import type { AgentProviderId } from '@shared/core/agents/agent-provider-registry'; import type { Automation } from '@shared/core/automations/automation'; import type { AutomationRun } from '@shared/core/automations/automation-run'; +import type { StoredAutomationTaskConfig } from '@shared/core/automations/config'; import type { CreateTaskParams } from '@shared/core/tasks/tasks'; import type { WorkspaceConfig } from '@shared/core/workspaces/workspace-config'; import { @@ -39,11 +40,17 @@ async function ensureProjectOpen(projectId: string) { return ok(project); } -function scopeWorkspaceConfigToRun(config: WorkspaceConfig, taskName: string): WorkspaceConfig { +function scopeWorkspaceConfigToRun( + config: WorkspaceConfig, + taskName: string, + branchNameOverride: StoredAutomationTaskConfig['branchNameOverride'] +): WorkspaceConfig { + const branchName = branchNameOverride?.trim() || taskName; + const git = config.git; - if (git.kind === 'create-branch') return { ...config, git: { ...git, branchName: taskName } }; + if (git.kind === 'create-branch') return { ...config, git: { ...git, branchName } }; if (git.kind === 'pr-branch' && git.taskBranch) - return { ...config, git: { ...git, taskBranch: taskName } }; + return { ...config, git: { ...git, taskBranch: branchName } }; return config; } @@ -82,7 +89,11 @@ export async function executeTaskCreate( onStepCompleted(failed); return err('no_workspace_config'); } - const workspaceConfig = scopeWorkspaceConfigToRun(taskConfig.workspaceConfig, taskName); + const workspaceConfig = scopeWorkspaceConfigToRun( + taskConfig.workspaceConfig, + taskName, + taskConfig.branchNameOverride + ); const provider = (automation.conversationConfig?.provider || (await appSettingsService.get('defaultAgent')) || diff --git a/apps/emdash-desktop/src/renderer/features/automations/components/AutomationSettingsFields.tsx b/apps/emdash-desktop/src/renderer/features/automations/components/AutomationSettingsFields.tsx index 4fbf3c823f..a63e3a539f 100644 --- a/apps/emdash-desktop/src/renderer/features/automations/components/AutomationSettingsFields.tsx +++ b/apps/emdash-desktop/src/renderer/features/automations/components/AutomationSettingsFields.tsx @@ -68,7 +68,11 @@ export function AutomationSettingsFields({ isWorkspaceProviderEnabled={isWorkspaceProviderEnabled} includeIssueContextByDefault={false} > - + ; @@ -114,6 +118,7 @@ export function useAutomationFormState( generatedName: seedConfig?.taskConfig.name, resetKey: effectiveProjectId, }); + const effectiveTaskName = taskName.effectiveTaskName || normalizeTaskName(name); const workspaceConfig = useWorkspaceConfig({ projectId: effectiveProjectId, @@ -122,7 +127,7 @@ export function useAutomationFormState( currentBranch, repositoryWorkspaceId, pr: null, // automations don't link PRs - taskName: taskName.effectiveTaskName || name, + taskName: effectiveTaskName, linkedIssue: null, createBranchAndWorktreeDefault: wsInitial.mode === 'new-worktree', resetKey: effectiveProjectId, @@ -158,14 +163,19 @@ export function useAutomationFormState( : wsConfig; const result: StoredAutomationTaskConfig = { - version: '1', + version: '2', taskConfig: { version: '1', - name: taskName.effectiveTaskName?.trim() || name.trim(), + name: effectiveTaskName, linkedIssue: seedConfig?.taskConfig.linkedIssue, initialStatus: seedConfig?.taskConfig.initialStatus, }, workspaceConfig: patchedConfig, + branchNameOverride: + workspaceConfig.branchNameState.isUserModified && + workspaceConfig.branchNameState.branchName.trim() + ? workspaceConfig.branchNameState.branchName.trim() + : undefined, }; // Strip MobX Proxy wrappers (e.g. fromBranch coming from getGitRepositoryStore) diff --git a/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/use-branch-name.ts b/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/use-branch-name.ts index 2080816a82..e47b19e534 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/use-branch-name.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/use-branch-name.ts @@ -1,12 +1,15 @@ import { useCallback, useMemo, useState } from 'react'; import { getGitRepositoryStore } from '@renderer/features/projects/stores/project-selectors'; import { useAppSettingsKey } from '@renderer/features/settings/use-app-settings-key'; +import { normalizeTaskName } from '@renderer/utils/taskNames'; import type { LinkedIssue } from '@shared/core/linked-issue'; import { resolveTaskBranchName } from '@shared/resolveTaskBranchName'; export type BranchNameState = { branchName: string; + customBranchNameSeed: string; setBranchName: (value: string) => void; + resetBranchName: () => void; isUserModified: boolean; branchAlreadyExists: boolean; }; @@ -16,8 +19,9 @@ export function useBranchName(opts: { linkedIssue?: LinkedIssue | null; projectId?: string; resetKey?: unknown; + initialBranchName?: string; }): BranchNameState { - const { taskName, linkedIssue, projectId, resetKey } = opts; + const { taskName, linkedIssue, projectId, resetKey, initialBranchName } = opts; const { value: project } = useAppSettingsKey('project'); const branchPrefix = project?.branchPrefix ?? ''; @@ -39,8 +43,8 @@ export function useBranchName(opts: { [branchPrefix, appendRandomSuffix, suffix, linkedIssue] ); - const [userValue, setUserValue] = useState(undefined); - const [isUserModified, setIsUserModified] = useState(false); + const [userValue, setUserValue] = useState(initialBranchName); + const [isUserModified, setIsUserModified] = useState(Boolean(initialBranchName)); const [prevResetKey, setPrevResetKey] = useState(resetKey); const [prevLinkedIssue, setPrevLinkedIssue] = useState(linkedIssue); @@ -48,8 +52,8 @@ export function useBranchName(opts: { if (resetKey !== prevResetKey) { setPrevResetKey(resetKey); setPrevLinkedIssue(linkedIssue); - setUserValue(undefined); - setIsUserModified(false); + setUserValue(initialBranchName); + setIsUserModified(Boolean(initialBranchName)); } // When the linked issue changes (user selects a different issue), clear user override. @@ -60,17 +64,30 @@ export function useBranchName(opts: { } const branchName = userValue !== undefined ? userValue : derive(taskName); + const customBranchNameSeed = normalizeTaskName(taskName); const setBranchName = useCallback((value: string) => { setUserValue(value); setIsUserModified(true); }, []); + const resetBranchName = useCallback(() => { + setUserValue(undefined); + setIsUserModified(false); + }, []); + // Pre-flight: check against the already-loaded local branch list in the repository store. const repo = projectId ? getGitRepositoryStore(projectId) : undefined; const branchAlreadyExists = branchName.trim().length > 0 && (repo?.localBranches.some((b) => b.branch === branchName) ?? false); - return { branchName, setBranchName, isUserModified, branchAlreadyExists }; + return { + branchName, + customBranchNameSeed, + setBranchName, + resetBranchName, + isUserModified, + branchAlreadyExists, + }; } diff --git a/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/use-workspace-config.ts b/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/use-workspace-config.ts index e0d1050e95..d9c6a698e2 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/use-workspace-config.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/use-workspace-config.ts @@ -111,6 +111,7 @@ export type WorkspaceConfigInitial = { mode?: WorkspaceMode; presetId?: WorkspacePresetId; selectedWorkspaceId?: string | null; + branchName?: string; }; export function useWorkspaceConfig(opts: { @@ -200,6 +201,7 @@ export function useWorkspaceConfig(opts: { linkedIssue, projectId, resetKey, + initialBranchName: initial?.branchName, }); // ── Resolved config ────────────────────────────────────────────────────── diff --git a/apps/emdash-desktop/src/renderer/features/tasks/task-config/branch-name-field.tsx b/apps/emdash-desktop/src/renderer/features/tasks/task-config/branch-name-field.tsx index f69b0d1015..9261e3e093 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/task-config/branch-name-field.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/task-config/branch-name-field.tsx @@ -5,31 +5,58 @@ import { Switch } from '@renderer/lib/ui/switch'; import { useTaskConfig } from './task-config-context'; interface BranchNameFieldProps { - state: Pick; + state: Pick< + BranchNameState, + | 'branchName' + | 'customBranchNameSeed' + | 'setBranchName' + | 'resetBranchName' + | 'isUserModified' + | 'branchAlreadyExists' + >; pushBranch?: boolean; onPushBranchChange?: (value: boolean) => void; } export function BranchNameField({ state, pushBranch, onPushBranchChange }: BranchNameFieldProps) { - const { autoBranchName } = useTaskConfig(); - const { branchName, setBranchName, branchAlreadyExists } = state; + const { autoBranchName, customBranchNameControl } = useTaskConfig(); + const { + branchName, + customBranchNameSeed, + setBranchName, + resetBranchName, + isUserModified, + branchAlreadyExists, + } = state; const showPush = pushBranch !== undefined && onPushBranchChange !== undefined; + function handleCustomBranchNameChange(checked: boolean) { + if (checked) { + setBranchName(customBranchNameSeed); + } else { + resetBranchName(); + } + } + return (
Branch name {autoBranchName ? ( - - Branch name will be auto-generated - +
+ + Branch name will be auto-generated + +
) : ( <> - setBranchName(value)} - placeholder="branch-name" - className="text-sm!" - /> +
+ setBranchName(value)} + placeholder="branch-name" + className="min-w-0 flex-1 text-sm!" + /> +
{branchAlreadyExists && (

This branch already exists — the task will check it out instead of creating a new one. @@ -43,6 +70,16 @@ export function BranchNameField({ state, pushBranch, onPushBranchChange }: Branc Push branch to remote

)} + {customBranchNameControl && ( +
+ + Custom branch name +
+ )} ); } diff --git a/apps/emdash-desktop/src/renderer/features/tasks/task-config/task-config-context.tsx b/apps/emdash-desktop/src/renderer/features/tasks/task-config/task-config-context.tsx index 508b6145d0..596bb02cde 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/task-config/task-config-context.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/task-config/task-config-context.tsx @@ -5,6 +5,8 @@ export type TaskConfigOptions = { showPrPresets?: boolean; /** When true, BranchNameField shows a read-only "auto-generated" placeholder instead of an input. Defaults to false. */ autoBranchName?: boolean; + /** When true, BranchNameField can switch between auto-generated and custom branch names. */ + customBranchNameControl?: boolean; }; type ResolvedTaskConfig = Required; @@ -12,6 +14,7 @@ type ResolvedTaskConfig = Required; const defaults: ResolvedTaskConfig = { showPrPresets: true, autoBranchName: false, + customBranchNameControl: false, }; const TaskConfigContext = createContext(defaults); @@ -20,10 +23,12 @@ export function TaskConfigProvider({ children, showPrPresets, autoBranchName, + customBranchNameControl, }: TaskConfigOptions & { children: React.ReactNode }) { const value: ResolvedTaskConfig = { showPrPresets: showPrPresets ?? defaults.showPrPresets, autoBranchName: autoBranchName ?? defaults.autoBranchName, + customBranchNameControl: customBranchNameControl ?? defaults.customBranchNameControl, }; return {children}; diff --git a/apps/emdash-desktop/src/shared/core/automations/config.ts b/apps/emdash-desktop/src/shared/core/automations/config.ts index 79eaf86eeb..f8a17cbabc 100644 --- a/apps/emdash-desktop/src/shared/core/automations/config.ts +++ b/apps/emdash-desktop/src/shared/core/automations/config.ts @@ -35,8 +35,19 @@ const storedAutomationTaskConfigV1Schema = z.object({ workspaceConfig: workspaceConfig.asNested(), }); +const storedAutomationTaskConfigV2Schema = z.object({ + version: z.literal('2'), + taskConfig: taskConfig.asNested(), + workspaceConfig: workspaceConfig.asNested(), + branchNameOverride: z.string().optional(), +}); + export const storedAutomationTaskConfig = defineVersionedSchema() .initial('1', storedAutomationTaskConfigV1Schema) + .version('2', storedAutomationTaskConfigV2Schema, (v1) => ({ + ...v1, + version: '2' as const, + })) .build(); export const storedAutomationTaskConfigSchema = storedAutomationTaskConfig.schema;