From e4ac0df51d4140124b4e06bcaf996fe47457d6b8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 30 May 2026 12:44:04 -0700 Subject: [PATCH] Clean up auto branch rename settings (#3378) Co-authored-by: Orca --- .../commit-message-text-generation.test.ts | 4 +- .../AutoRenameBranchFromWorkSetting.tsx | 479 ++++++++++++++++++ .../settings/CommitMessageAiPane.test.tsx | 2 +- .../settings/CommitMessageAiPane.tsx | 285 ++++------- .../src/components/settings/GitPane.test.ts | 56 ++ .../src/components/settings/GitPane.tsx | 85 ++-- .../RepositorySourceControlAiSection.tsx | 30 +- .../src/components/settings/Settings.tsx | 75 ++- .../settings/auto-rename-branch-search.ts | 35 ++ .../settings/commit-message-ai-search.ts | 22 +- .../src/components/settings/git-search.ts | 7 +- .../components/settings/repository-search.ts | 3 +- src/shared/branch-name-from-work.test.ts | 4 +- src/shared/branch-name-from-work.ts | 11 +- src/shared/commit-message-generation.test.ts | 6 +- src/shared/commit-message-generation.ts | 13 +- src/shared/commit-message-prompt.test.ts | 4 +- src/shared/commit-message-prompt.ts | 2 +- src/shared/constants.test.ts | 4 + src/shared/pull-request-generation.test.ts | 2 +- src/shared/pull-request-generation.ts | 10 +- src/shared/source-control-ai-types.ts | 4 + 22 files changed, 816 insertions(+), 327 deletions(-) create mode 100644 src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx create mode 100644 src/renderer/src/components/settings/GitPane.test.ts create mode 100644 src/renderer/src/components/settings/auto-rename-branch-search.ts diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index 08f3c292f..0f0be2b59 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -1334,7 +1334,7 @@ describe('generateBranchNameFromContext', () => { }) }) - it('includes branch-name custom instructions in the generated prompt', async () => { + it('includes the branch-name custom prompt in the generated prompt', async () => { let prompt = '' await generateBranchNameFromContext( { firstPrompt: 'Fix login flow' }, @@ -1360,7 +1360,7 @@ describe('generateBranchNameFromContext', () => { } ) - expect(prompt).toContain('Additional user instructions:') + expect(prompt).toContain('Additional user prompt:') expect(prompt).toContain('Prefer auth terminology.') }) }) diff --git a/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx b/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx new file mode 100644 index 000000000..6f5429273 --- /dev/null +++ b/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx @@ -0,0 +1,479 @@ +/* eslint-disable max-lines -- Why: the setting owns one collapsed form with + queued writes, model selection, and prompt draft state. Splitting the + tiny subcontrols would make the settings write flow harder to audit. */ +import { useEffect, useMemo, useRef, useState } from 'react' +import { ChevronDown } from 'lucide-react' +import type { GlobalSettings } from '../../../../shared/types' +import type { + SourceControlAiModelChoice, + SourceControlAiSettingsPatch, + SourceControlAiSettings +} from '../../../../shared/source-control-ai-types' +import { buildBranchNamePrompt } from '../../../../shared/branch-name-from-work' +import { + clearSourceControlAiModelChoiceForHost, + normalizeSourceControlAiSettings, + readSourceControlAiModelChoiceForHost, + selectSourceControlAiModelChoiceForHost +} from '../../../../shared/source-control-ai' +import { + getCommitMessageAgentCapability, + isCustomAgentId, + resolveCommitMessageAgentChoice, + type CommitMessageAgentCapability, + type CommitMessageModelCapability +} from '../../../../shared/commit-message-agent-spec' +import { + getCommitMessageModelDiscoveryHostKeyForScope, + LOCAL_COMMIT_MESSAGE_HOST_KEY +} from '../../../../shared/commit-message-host-key' +import { getConnectionId } from '@/lib/connection-context' +import { cn } from '@/lib/utils' +import { getRuntimeGitScope } from '../../runtime/runtime-git-client' +import { useAppStore } from '../../store' +import { useActiveWorktree } from '../../store/selectors' +import { Button } from '../ui/button' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible' +import { Label } from '../ui/label' +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { AUTO_RENAME_BRANCH_ADVANCED_SEARCH_ENTRIES } from './auto-rename-branch-search' +import { SearchableSetting } from './SearchableSetting' +import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search' + +type AutoRenameBranchFromWorkSettingProps = { + settings: GlobalSettings + updateSettings: (updates: Partial) => void | Promise + writeSourceControlAiSettings: (patch: SourceControlAiSettingsPatch) => Promise + forceVisible?: boolean + onBranchPromptDirtyChange?: (dirty: boolean) => void + branchPromptDiscardSignal?: number + settingsSearchQuery?: string +} + +const INHERIT_BRANCH_MODEL_VALUE = '__inherit_branch_model__' +const BUILT_IN_BRANCH_NAME_PROMPT = buildBranchNamePrompt({ + firstPrompt: '{first agent prompt}', + assistantMessage: '{agent initial response, when available}' +}) +export function shouldOpenAutoRenameBranchAdvanced(searchQuery: string): boolean { + return ( + normalizeSettingsSearchQuery(searchQuery) !== '' && + matchesSettingsSearch(searchQuery, AUTO_RENAME_BRANCH_ADVANCED_SEARCH_ENTRIES) + ) +} + +function readSourceControlSettings(settings: GlobalSettings): SourceControlAiSettings { + return normalizeSourceControlAiSettings(settings.sourceControlAi, settings.commitMessageAi) +} + +function mergeModelCapabilities( + fallbackModels: CommitMessageModelCapability[], + discoveredModels: CommitMessageModelCapability[] | undefined +): CommitMessageModelCapability[] { + const models: CommitMessageModelCapability[] = [] + const seen = new Set() + for (const model of [...(discoveredModels ?? []), ...fallbackModels]) { + if (!model.id || seen.has(model.id)) { + continue + } + seen.add(model.id) + models.push(model) + } + return models +} + +function getCapabilityWithDiscoveredModels( + config: SourceControlAiSettings, + capability: CommitMessageAgentCapability, + hostKey: string +): CommitMessageAgentCapability { + const discoveredModels = + config.discoveredModelsByAgentByHost?.[hostKey]?.[capability.id] ?? + (hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY + ? config.discoveredModelsByAgent?.[capability.id] + : undefined) + const models = mergeModelCapabilities(capability.models, discoveredModels) + const defaultModelId = models.some((model) => model.id === capability.defaultModelId) + ? capability.defaultModelId + : (models[0]?.id ?? capability.defaultModelId) + return { ...capability, models, defaultModelId } +} + +function resolveSelectedThinking( + config: SourceControlAiSettings, + model: CommitMessageModelCapability, + operationChoice: SourceControlAiModelChoice | undefined +): string | undefined { + if (!model.thinkingLevels) { + return undefined + } + const persisted = + operationChoice?.selectedThinkingByModel?.[model.id] ?? config.selectedThinkingByModel[model.id] + return model.thinkingLevels.some((level) => level.id === persisted) + ? persisted + : model.defaultThinkingLevel +} + +export function AutoRenameBranchFromWorkSetting({ + settings, + updateSettings, + writeSourceControlAiSettings, + forceVisible = false, + onBranchPromptDirtyChange, + branchPromptDiscardSignal, + settingsSearchQuery +}: AutoRenameBranchFromWorkSettingProps): React.JSX.Element { + const storeSearchQuery = useAppStore((state) => state.settingsSearchQuery) + const searchQuery = settingsSearchQuery ?? storeSearchQuery + const activeWorktree = useActiveWorktree() + const activeConnectionId = getConnectionId(activeWorktree?.id ?? null) + const discoveryHostKey = getCommitMessageModelDiscoveryHostKeyForScope( + activeWorktree?.id ? getRuntimeGitScope(settings, activeConnectionId) : activeConnectionId + ) + const config = readSourceControlSettings(settings) + const [optionsOpen, setOptionsOpen] = useState(false) + const advancedSearchOpen = shouldOpenAutoRenameBranchAdvanced(searchQuery) + const advancedOpen = optionsOpen || advancedSearchOpen + const persistedBranchNamePrompt = config.instructionsByOperation.branchName ?? '' + const persistedBranchNamePromptRef = useRef(persistedBranchNamePrompt) + persistedBranchNamePromptRef.current = persistedBranchNamePrompt + const [branchNamePromptDraft, setBranchNamePromptDraft] = useState(persistedBranchNamePrompt) + const [isSavingPrompt, setIsSavingPrompt] = useState(false) + const branchNamePromptDirty = branchNamePromptDraft !== persistedBranchNamePrompt + + useEffect(() => { + if (!branchNamePromptDirty) { + setBranchNamePromptDraft(persistedBranchNamePrompt) + } + }, [branchNamePromptDirty, persistedBranchNamePrompt]) + + useEffect(() => { + setBranchNamePromptDraft(persistedBranchNamePromptRef.current) + // Why: Settings owns the discard confirmation, but the draft lives here so + // the row can keep its prompt-specific save/discard affordances. + }, [branchPromptDiscardSignal]) + + useEffect(() => { + onBranchPromptDirtyChange?.(branchNamePromptDirty) + }, [branchNamePromptDirty, onBranchPromptDirtyChange]) + + useEffect( + () => () => { + onBranchPromptDirtyChange?.(false) + }, + [onBranchPromptDirtyChange] + ) + + const resolvedAgentId = resolveCommitMessageAgentChoice( + config.agentId, + settings.defaultTuiAgent, + settings.disabledTuiAgents + ) + const activeAgentId = + resolvedAgentId && !isCustomAgentId(resolvedAgentId) ? resolvedAgentId : null + const activeCapability = useMemo(() => { + if (!activeAgentId) { + return undefined + } + const capability = getCommitMessageAgentCapability(activeAgentId) + return capability + ? getCapabilityWithDiscoveredModels(config, capability, discoveryHostKey) + : undefined + }, [activeAgentId, config, discoveryHostKey]) + const branchModelChoice = config.modelOverridesByOperation?.branchName + const branchModelOverrideId = activeCapability + ? readSourceControlAiModelChoiceForHost( + branchModelChoice, + discoveryHostKey, + activeCapability.id + ) + : undefined + const selectedBranchModel = branchModelOverrideId + ? activeCapability?.models.find((model) => model.id === branchModelOverrideId) + : undefined + const selectedBranchThinking = selectedBranchModel + ? resolveSelectedThinking(config, selectedBranchModel, branchModelChoice) + : undefined + + const onBranchModelChange = (modelId: string): void => { + if (!activeCapability) { + return + } + if (modelId === INHERIT_BRANCH_MODEL_VALUE) { + void writeSourceControlAiSettings((current) => { + const nextOverrides = { ...current.modelOverridesByOperation } + const nextChoice = clearSourceControlAiModelChoiceForHost( + nextOverrides.branchName, + discoveryHostKey, + activeCapability.id + ) + if (nextChoice) { + nextOverrides.branchName = nextChoice + } else { + delete nextOverrides.branchName + } + return { modelOverridesByOperation: nextOverrides } + }) + return + } + const model = activeCapability.models.find((candidate) => candidate.id === modelId) + if (!model) { + return + } + void writeSourceControlAiSettings((current) => { + const nextChoice = selectSourceControlAiModelChoiceForHost( + current.modelOverridesByOperation?.branchName, + discoveryHostKey, + activeCapability.id, + model.id + ) + if ( + model.thinkingLevels && + model.defaultThinkingLevel && + !nextChoice.selectedThinkingByModel?.[model.id] + ) { + nextChoice.selectedThinkingByModel = { + ...nextChoice.selectedThinkingByModel, + [model.id]: model.defaultThinkingLevel + } + } + return { + modelOverridesByOperation: { + ...current.modelOverridesByOperation, + branchName: nextChoice + } + } + }) + } + + const onBranchThinkingChange = (modelId: string, thinkingId: string): void => { + void writeSourceControlAiSettings((current) => ({ + modelOverridesByOperation: { + ...current.modelOverridesByOperation, + branchName: { + ...current.modelOverridesByOperation?.branchName, + selectedThinkingByModel: { + ...current.modelOverridesByOperation?.branchName?.selectedThinkingByModel, + [modelId]: thinkingId + } + } + } + })) + } + + const onSavePrompt = async (): Promise => { + if (!branchNamePromptDirty || isSavingPrompt) { + return + } + setIsSavingPrompt(true) + try { + await writeSourceControlAiSettings((current) => ({ + instructionsByOperation: { + ...current.instructionsByOperation, + branchName: branchNamePromptDraft + } + })) + } finally { + setIsSavingPrompt(false) + } + } + + const onDiscardPrompt = (): void => { + setBranchNamePromptDraft(persistedBranchNamePrompt) + } + + return ( + +
+
+ +

+ When an agent starts working in a new workspace, Orca renames its auto-generated branch + (e.g. Nautilus) to a short name summarizing the task. Only branches Orca + named itself are renamed, and never after they have been pushed. +

+
+ +
+ + + + + + +
+
+
+ +

+ Appended to Orca's{' '} + + + + + +

+
+                          {BUILT_IN_BRANCH_NAME_PROMPT}
+                        
+
+ + + . Orca generates only the final segment, like{' '} + fix-login-flow; your branch prefix setting + still applies. +

+
+