Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}

Expand Down Expand Up @@ -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')) ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ export function AutomationSettingsFields({
isWorkspaceProviderEnabled={isWorkspaceProviderEnabled}
includeIssueContextByDefault={false}
>
<TaskConfigProvider showPrPresets={false} autoBranchName={true}>
<TaskConfigProvider
showPrPresets={false}
autoBranchName={!workspaceConfig.branchNameState.isUserModified}
customBranchNameControl
>
<TaskConfigPanel
tabs={[
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { GitBranchRef } from '@emdash/core/git';
import { useMemo, useState } from 'react';
import { DEFAULT_CRON_STATE, toCron } from '@renderer/lib/CronPicker/cron-utils';
import { normalizeTaskName } from '@renderer/utils/taskNames';
import { isValidProviderId } from '@shared/core/agents/agent-provider-registry';
import type { Automation } from '@shared/core/automations/automation';
import type { StoredAutomationTaskConfig, TriggerConfig } from '@shared/core/automations/config';
Expand Down Expand Up @@ -29,6 +30,7 @@ function workspaceInitialFromConfig(
): WorkspaceConfigInitial & { fromBranch?: GitBranchRef; pushBranch?: boolean } {
if (!config) return { mode: 'new-worktree', presetId: 'new-worktree' };
const { git, workspace } = config.workspaceConfig;
const branchName = config.branchNameOverride;

if (workspace.kind === 'byoi' || (workspace as { host?: string }).host === 'byoi') {
return { mode: 'sandbox', presetId: 'sandbox' };
Expand All @@ -40,6 +42,7 @@ function workspaceInitialFromConfig(
presetId: 'new-worktree',
fromBranch: git.fromBranch,
pushBranch: git.pushBranch,
branchName,
};
}

Expand All @@ -49,13 +52,14 @@ function workspaceInitialFromConfig(
mode: 'existing',
presetId: 'use-existing',
selectedWorkspaceId: workspace.workspaceId,
branchName,
};
}
// repo-root or unknown
return { mode: 'existing', presetId: 'repo-root' };
return { mode: 'existing', presetId: 'repo-root', branchName };
}

return { mode: 'new-worktree', presetId: 'new-worktree' };
return { mode: 'new-worktree', presetId: 'new-worktree', branchName };
}

export type AutomationFormState = ReturnType<typeof useAutomationFormState>;
Expand Down Expand Up @@ -114,6 +118,7 @@ export function useAutomationFormState(
generatedName: seedConfig?.taskConfig.name,
resetKey: effectiveProjectId,
});
const effectiveTaskName = taskName.effectiveTaskName || normalizeTaskName(name);

const workspaceConfig = useWorkspaceConfig({
projectId: effectiveProjectId,
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
};
Expand All @@ -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 ?? '';
Expand All @@ -39,17 +43,17 @@ export function useBranchName(opts: {
[branchPrefix, appendRandomSuffix, suffix, linkedIssue]
);

const [userValue, setUserValue] = useState<string | undefined>(undefined);
const [isUserModified, setIsUserModified] = useState(false);
const [userValue, setUserValue] = useState<string | undefined>(initialBranchName);
const [isUserModified, setIsUserModified] = useState(Boolean(initialBranchName));
const [prevResetKey, setPrevResetKey] = useState(resetKey);
const [prevLinkedIssue, setPrevLinkedIssue] = useState(linkedIssue);

// Reset when the project changes.
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.
Expand All @@ -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,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export type WorkspaceConfigInitial = {
mode?: WorkspaceMode;
presetId?: WorkspacePresetId;
selectedWorkspaceId?: string | null;
branchName?: string;
};

export function useWorkspaceConfig(opts: {
Expand Down Expand Up @@ -200,6 +201,7 @@ export function useWorkspaceConfig(opts: {
linkedIssue,
projectId,
resetKey,
initialBranchName: initial?.branchName,
});

// ── Resolved config ──────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,58 @@ import { Switch } from '@renderer/lib/ui/switch';
import { useTaskConfig } from './task-config-context';

interface BranchNameFieldProps {
state: Pick<BranchNameState, 'branchName' | 'setBranchName' | 'branchAlreadyExists'>;
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();
}
}
Comment thread
janburzinski marked this conversation as resolved.

return (
<div className="flex flex-col rounded-lg border border-border px-2.5 py-2">
<span className="flex items-center gap-1.5 text-xs text-foreground-passive">Branch name</span>
{autoBranchName ? (
<span className="py-1 text-sm text-foreground-muted italic">
Branch name will be auto-generated
</span>
<div className="flex items-center gap-2 py-1">
<span className="min-w-0 flex-1 truncate text-sm text-foreground-muted italic">
Branch name will be auto-generated
</span>
</div>
) : (
<>
<EditableNameField
value={branchName}
onChange={(value) => setBranchName(value)}
placeholder="branch-name"
className="text-sm!"
/>
<div className="flex items-center gap-2">
<EditableNameField
value={branchName}
onChange={(value) => setBranchName(value)}
placeholder="branch-name"
className="min-w-0 flex-1 text-sm!"
/>
</div>
{branchAlreadyExists && (
<p className="text-muted-foreground mt-1 text-xs">
This branch already exists — the task will check it out instead of creating a new one.
Expand All @@ -43,6 +70,16 @@ export function BranchNameField({ state, pushBranch, onPushBranchChange }: Branc
<FieldLabel>Push branch to remote</FieldLabel>
</div>
)}
{customBranchNameControl && (
<div className="mt-1 flex items-center gap-1.5">
<Switch
size="sm"
checked={isUserModified}
onCheckedChange={handleCustomBranchNameChange}
/>
<FieldLabel>Custom branch name</FieldLabel>
</div>
)}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ 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<TaskConfigOptions>;

const defaults: ResolvedTaskConfig = {
showPrPresets: true,
autoBranchName: false,
customBranchNameControl: false,
};

const TaskConfigContext = createContext<ResolvedTaskConfig>(defaults);
Expand All @@ -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 <TaskConfigContext.Provider value={value}>{children}</TaskConfigContext.Provider>;
Expand Down
Loading
Loading