Clean up auto branch rename settings (#3378)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-30 12:44:04 -07:00 committed by GitHub
parent 4ea2eae432
commit e4ac0df51d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 816 additions and 327 deletions

View File

@ -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.')
})
})

View File

@ -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<GlobalSettings>) => void | Promise<void>
writeSourceControlAiSettings: (patch: SourceControlAiSettingsPatch) => Promise<void>
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<string>()
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<void> => {
if (!branchNamePromptDirty || isSavingPrompt) {
return
}
setIsSavingPrompt(true)
try {
await writeSourceControlAiSettings((current) => ({
instructionsByOperation: {
...current.instructionsByOperation,
branchName: branchNamePromptDraft
}
}))
} finally {
setIsSavingPrompt(false)
}
}
const onDiscardPrompt = (): void => {
setBranchNamePromptDraft(persistedBranchNamePrompt)
}
return (
<SearchableSetting
title="Auto-Rename Branch"
description="Rename the auto-generated branch based on the work once an agent starts."
keywords={[
'branch',
'rename',
'auto',
'creature name',
'agent',
'prompt',
'worktree',
'model',
'prompt',
'slug'
]}
forceVisible={forceVisible || branchNamePromptDirty || advancedSearchOpen}
className="space-y-3 py-2"
>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label>Auto-Rename Branch</Label>
<p className="text-xs text-muted-foreground">
When an agent starts working in a new workspace, Orca renames its auto-generated branch
(e.g. <code>Nautilus</code>) to a short name summarizing the task. Only branches Orca
named itself are renamed, and never after they have been pushed.
</p>
</div>
<button
role="switch"
aria-checked={settings.autoRenameBranchFromWork}
onClick={() =>
updateSettings({
autoRenameBranchFromWork: !settings.autoRenameBranchFromWork
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.autoRenameBranchFromWork ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.autoRenameBranchFromWork ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
<Collapsible open={advancedOpen} onOpenChange={setOptionsOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="-ml-2 h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
Advanced
<ChevronDown
className={cn('size-3.5 transition-transform', advancedOpen && 'rotate-180')}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 space-y-3 rounded-md border border-border/60 bg-muted/20 px-3 py-3">
<div className="space-y-2">
<div className="space-y-0.5">
<Label htmlFor="git-auto-rename-branch-name-prompt">Branch name prompt</Label>
<p className="text-xs text-muted-foreground">
Appended to Orca&apos;s{' '}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="inline rounded-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
built-in branch-name prompt
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
className="w-[520px] max-w-[calc(100vw-2rem)] p-3"
>
<div>
<pre className="scrollbar-sleek max-h-72 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-background px-3 py-2 font-mono text-[11px] leading-relaxed text-muted-foreground">
{BUILT_IN_BRANCH_NAME_PROMPT}
</pre>
</div>
</PopoverContent>
</Popover>
. Orca generates only the final segment, like{' '}
<code className="font-mono">fix-login-flow</code>; your branch prefix setting
still applies.
</p>
</div>
<textarea
id="git-auto-rename-branch-name-prompt"
rows={4}
value={branchNamePromptDraft}
onChange={(event) => setBranchNamePromptDraft(event.target.value)}
placeholder="Prefer domain nouns from the task, avoid ticket IDs, and keep names reviewer-friendly."
className="w-full resize-y rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring"
/>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-muted-foreground">
{branchNamePromptDirty ? 'Unsaved changes' : 'Saved'}
</p>
<div className="flex items-center gap-2">
{branchNamePromptDirty ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={onDiscardPrompt}
disabled={isSavingPrompt}
>
Discard
</Button>
) : null}
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => void onSavePrompt()}
disabled={!branchNamePromptDirty || isSavingPrompt}
>
{isSavingPrompt ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
</div>
<div className="flex flex-col gap-3 border-t border-border/50 pt-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-0.5">
<Label>Branch name model</Label>
<p className="text-xs text-muted-foreground">
Use a different model for branch name generation.
</p>
</div>
{activeCapability ? (
<div className="flex w-full flex-col items-end gap-2 sm:w-auto">
<Select
value={branchModelOverrideId ?? INHERIT_BRANCH_MODEL_VALUE}
onValueChange={onBranchModelChange}
>
<SelectTrigger size="sm" className="h-8 w-full shrink-0 text-xs sm:w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={INHERIT_BRANCH_MODEL_VALUE} className="cursor-pointer">
Use default model
</SelectItem>
{activeCapability.models.map((model) => (
<SelectItem key={model.id} value={model.id} className="cursor-pointer">
{model.label}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedBranchModel?.thinkingLevels && selectedBranchThinking ? (
<div className="flex w-full items-center justify-end gap-2 sm:w-auto">
<span className="text-[11px] text-muted-foreground">Thinking</span>
<Select
value={selectedBranchThinking}
onValueChange={(value) =>
onBranchThinkingChange(selectedBranchModel.id, value)
}
>
<SelectTrigger size="sm" className="h-7 w-[150px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{selectedBranchModel.thinkingLevels.map((level) => (
<SelectItem key={level.id} value={level.id} className="cursor-pointer">
{level.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
</div>
) : (
<p className="max-w-[260px] text-right text-xs text-muted-foreground">
Choose a Source Control AI agent that supports model selection.
</p>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
</SearchableSetting>
)
}

View File

@ -73,7 +73,7 @@ describe('CommitMessageAiPane', () => {
expect(markup).toContain('Thinking effort')
expect(markup).toContain('Commit message model')
expect(markup).toContain('PR details model')
expect(markup).toContain('Branch name model')
expect(markup).not.toContain('Branch name model')
expect(markup).toContain('Higher effort produces more careful messages')
expect(markup).toContain('Use Conventional Commits.')
expect(markup).toContain('Save')

View File

@ -7,6 +7,7 @@ import { RefreshCw, Terminal } from 'lucide-react'
import type { GlobalSettings, TuiAgent } from '../../../../shared/types'
import type {
SourceControlAiOperation,
SourceControlAiSettingsPatch,
SourceControlAiSettings
} from '../../../../shared/source-control-ai-types'
import {
@ -46,14 +47,11 @@ import { matchesSettingsSearch } from './settings-search'
type CommitMessageAiPaneProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void | Promise<void>
writeSourceControlAiSettings?: (patch: SourceControlAiSettingsPatch) => Promise<void>
onCustomPromptDirtyChange?: (dirty: boolean) => void
customPromptDiscardSignal?: number
}
type SourceControlAiConfigPatch =
| Partial<SourceControlAiSettings>
| ((current: SourceControlAiSettings) => Partial<SourceControlAiSettings>)
type ModelDiscoveryState = {
status: 'idle' | 'loading' | 'ready' | 'error'
hostKey: string
@ -204,6 +202,7 @@ export function getCommitMessageSettingsPaneDiscoveryHostKey(
export function CommitMessageAiPane({
settings,
updateSettings,
writeSourceControlAiSettings,
onCustomPromptDirtyChange,
customPromptDiscardSignal
}: CommitMessageAiPaneProps): React.JSX.Element {
@ -222,66 +221,41 @@ export function CommitMessageAiPane({
const [modelDiscoveryByAgent, setModelDiscoveryByAgent] = useState<
Partial<Record<TuiAgent, ModelDiscoveryState>>
>({})
const persistedCommitInstructions = config.instructionsByOperation.commitMessage ?? ''
const persistedPullRequestInstructions = config.instructionsByOperation.pullRequest ?? ''
const persistedBranchNameInstructions = config.instructionsByOperation.branchName ?? ''
const [commitInstructionsDraft, setCommitInstructionsDraft] = useState(
persistedCommitInstructions
)
const [pullRequestInstructionsDraft, setPullRequestInstructionsDraft] = useState(
persistedPullRequestInstructions
)
const [branchNameInstructionsDraft, setBranchNameInstructionsDraft] = useState(
persistedBranchNameInstructions
)
const [isSavingInstructions, setIsSavingInstructions] = useState(false)
const persistedInstructionsRef = useRef({
commitMessage: persistedCommitInstructions,
pullRequest: persistedPullRequestInstructions,
branchName: persistedBranchNameInstructions
const persistedCommitPrompt = config.instructionsByOperation.commitMessage ?? ''
const persistedPullRequestPrompt = config.instructionsByOperation.pullRequest ?? ''
const [commitPromptDraft, setCommitPromptDraft] = useState(persistedCommitPrompt)
const [pullRequestPromptDraft, setPullRequestPromptDraft] = useState(persistedPullRequestPrompt)
const [isSavingPrompt, setIsSavingPrompt] = useState(false)
const persistedPromptsRef = useRef({
commitMessage: persistedCommitPrompt,
pullRequest: persistedPullRequestPrompt
})
const isCommitInstructionsDirty = commitInstructionsDraft !== persistedCommitInstructions
const isPullRequestInstructionsDirty =
pullRequestInstructionsDraft !== persistedPullRequestInstructions
const isBranchNameInstructionsDirty =
branchNameInstructionsDraft !== persistedBranchNameInstructions
const isCustomPromptDirty =
isCommitInstructionsDirty || isPullRequestInstructionsDirty || isBranchNameInstructionsDirty
const isCommitPromptDirty = commitPromptDraft !== persistedCommitPrompt
const isPullRequestPromptDirty = pullRequestPromptDraft !== persistedPullRequestPrompt
const isCustomPromptDirty = isCommitPromptDirty || isPullRequestPromptDirty
useEffect(() => {
persistedInstructionsRef.current = {
commitMessage: persistedCommitInstructions,
pullRequest: persistedPullRequestInstructions,
branchName: persistedBranchNameInstructions
persistedPromptsRef.current = {
commitMessage: persistedCommitPrompt,
pullRequest: persistedPullRequestPrompt
}
}, [
persistedBranchNameInstructions,
persistedCommitInstructions,
persistedPullRequestInstructions
])
}, [persistedCommitPrompt, persistedPullRequestPrompt])
useEffect(() => {
if (!isCommitInstructionsDirty) {
setCommitInstructionsDraft(persistedCommitInstructions)
if (!isCommitPromptDirty) {
setCommitPromptDraft(persistedCommitPrompt)
}
}, [isCommitInstructionsDirty, persistedCommitInstructions])
}, [isCommitPromptDirty, persistedCommitPrompt])
useEffect(() => {
if (!isPullRequestInstructionsDirty) {
setPullRequestInstructionsDraft(persistedPullRequestInstructions)
if (!isPullRequestPromptDirty) {
setPullRequestPromptDraft(persistedPullRequestPrompt)
}
}, [isPullRequestInstructionsDirty, persistedPullRequestInstructions])
}, [isPullRequestPromptDirty, persistedPullRequestPrompt])
useEffect(() => {
if (!isBranchNameInstructionsDirty) {
setBranchNameInstructionsDraft(persistedBranchNameInstructions)
}
}, [isBranchNameInstructionsDirty, persistedBranchNameInstructions])
useEffect(() => {
setCommitInstructionsDraft(persistedInstructionsRef.current.commitMessage)
setPullRequestInstructionsDraft(persistedInstructionsRef.current.pullRequest)
setBranchNameInstructionsDraft(persistedInstructionsRef.current.branchName)
setCommitPromptDraft(persistedPromptsRef.current.commitMessage)
setPullRequestPromptDraft(persistedPromptsRef.current.pullRequest)
// Why: parent navigation guards use this signal after the user confirms
// they want to leave without saving the prompt draft.
}, [customPromptDiscardSignal])
@ -364,7 +338,7 @@ export function CommitMessageAiPane({
const activeDiscovery =
rawActiveDiscovery?.hostKey === discoveryHostKey ? rawActiveDiscovery : undefined
const writeConfig = (patch: SourceControlAiConfigPatch): Promise<void> => {
const localWriteConfig = (patch: SourceControlAiSettingsPatch): Promise<void> => {
const next = settingsWriteQueueRef.current
.catch(() => undefined)
.then(async () => {
@ -376,6 +350,7 @@ export function CommitMessageAiPane({
settingsWriteQueueRef.current = next
return next
}
const writeConfig = writeSourceControlAiSettings ?? localWriteConfig
const refreshModels = async (agentId: TuiAgent): Promise<void> => {
const capability =
@ -714,23 +689,13 @@ export function CommitMessageAiPane({
}))
}
const onSaveInstructions = async (operation: SourceControlAiOperation): Promise<void> => {
const draft =
operation === 'commitMessage'
? commitInstructionsDraft
: operation === 'pullRequest'
? pullRequestInstructionsDraft
: branchNameInstructionsDraft
const dirty =
operation === 'commitMessage'
? isCommitInstructionsDirty
: operation === 'pullRequest'
? isPullRequestInstructionsDirty
: isBranchNameInstructionsDirty
if (!dirty || isSavingInstructions) {
const onSavePrompt = async (operation: SourceControlAiOperation): Promise<void> => {
const draft = operation === 'commitMessage' ? commitPromptDraft : pullRequestPromptDraft
const dirty = operation === 'commitMessage' ? isCommitPromptDirty : isPullRequestPromptDirty
if (!dirty || isSavingPrompt) {
return
}
setIsSavingInstructions(true)
setIsSavingPrompt(true)
try {
await writeConfig((current) => ({
instructionsByOperation: {
@ -739,20 +704,16 @@ export function CommitMessageAiPane({
}
}))
} finally {
setIsSavingInstructions(false)
setIsSavingPrompt(false)
}
}
const onDiscardInstructions = (operation: SourceControlAiOperation): void => {
const onDiscardPrompt = (operation: SourceControlAiOperation): void => {
if (operation === 'commitMessage') {
setCommitInstructionsDraft(persistedCommitInstructions)
setCommitPromptDraft(persistedCommitPrompt)
return
}
if (operation === 'branchName') {
setBranchNameInstructionsDraft(persistedBranchNameInstructions)
return
}
setPullRequestInstructionsDraft(persistedPullRequestInstructions)
setPullRequestPromptDraft(persistedPullRequestPrompt)
}
const onPrDefaultChange = (
@ -1043,8 +1004,7 @@ export function CommitMessageAiPane({
activeModel &&
matchesSettingsSearch(searchQuery, {
title: 'Advanced model overrides',
description:
'Optional per-operation model choices for commit messages, PR details, and branch names.',
description: 'Optional per-operation model choices for commit messages and PR details.',
keywords: ['model', 'override', 'commit', 'pull request', 'pr', 'thinking']
})
) {
@ -1062,26 +1022,21 @@ export function CommitMessageAiPane({
operation: 'pullRequest',
label: 'PR details model',
description: 'Use a different model for pull request title and description generation.'
},
{
operation: 'branchName',
label: 'Branch name model',
description: 'Use a different model for branch name generation.'
}
]
sections.push(
<SearchableSetting
key="model-overrides"
title="Advanced model overrides"
description="Optional per-operation model choices for commit messages, PR details, and branch names."
description="Optional per-operation model choices for commit messages and PR details."
keywords={['model', 'override', 'commit', 'pull request', 'pr', 'thinking']}
className="space-y-3 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Advanced model overrides</Label>
<p className="text-xs text-muted-foreground">
Leave these inherited unless commit messages, PR details, or branch names need different
model behavior.
Leave these inherited unless commit messages or PR details need different model
behavior.
</p>
</div>
<div className="space-y-3">
@ -1161,50 +1116,50 @@ export function CommitMessageAiPane({
}
if (
(config.enabled || isCommitInstructionsDirty) &&
(isCommitInstructionsDirty ||
(config.enabled || isCommitPromptDirty) &&
(isCommitPromptDirty ||
matchesSettingsSearch(searchQuery, {
title: 'Commit message instructions',
description: 'Optional instructions appended only to commit-message prompts.',
keywords: ['prompt', 'instructions', 'conventional commits', 'gitmoji', 'style']
title: 'Commit message prompt',
description: 'Additional prompt text appended only when generating commit messages.',
keywords: ['prompt', 'conventional commits', 'gitmoji', 'style']
}))
) {
sections.push(
<SearchableSetting
key="commit-instructions"
title="Commit message instructions"
description="Optional instructions appended only to commit-message prompts."
keywords={['prompt', 'instructions', 'conventional commits', 'gitmoji', 'style']}
forceVisible={isCommitInstructionsDirty}
key="commit-prompt"
title="Commit message prompt"
description="Additional prompt text appended only when generating commit messages."
keywords={['prompt', 'conventional commits', 'gitmoji', 'style']}
forceVisible={isCommitPromptDirty}
className="space-y-2 px-1 py-2"
>
<div className="space-y-0.5">
<Label htmlFor="source-control-ai-commit-instructions">Commit message instructions</Label>
<Label htmlFor="source-control-ai-commit-prompt">Commit message prompt</Label>
<p className="text-xs text-muted-foreground">
Appended only when generating commit messages. Use this for Conventional Commits, ticket
prefixes, or any other commit style your team prefers.
This prompt is appended only when generating commit messages. Use it for Conventional
Commits, ticket prefixes, or any other commit style your team prefers.
</p>
</div>
<textarea
id="source-control-ai-commit-instructions"
id="source-control-ai-commit-prompt"
rows={4}
value={commitInstructionsDraft}
onChange={(e) => setCommitInstructionsDraft(e.target.value)}
value={commitPromptDraft}
onChange={(e) => setCommitPromptDraft(e.target.value)}
placeholder="Use Conventional Commits format (feat:, fix:, ...). Reference the ticket key when present."
className="w-full resize-y rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring"
/>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-muted-foreground">
{isCommitInstructionsDirty ? 'Unsaved changes' : 'Saved'}
{isCommitPromptDirty ? 'Unsaved changes' : 'Saved'}
</p>
<div className="flex items-center gap-2">
{isCommitInstructionsDirty ? (
{isCommitPromptDirty ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onDiscardInstructions('commitMessage')}
disabled={isSavingInstructions}
onClick={() => onDiscardPrompt('commitMessage')}
disabled={isSavingPrompt}
>
Discard
</Button>
@ -1213,10 +1168,10 @@ export function CommitMessageAiPane({
type="button"
variant="secondary"
size="xs"
onClick={() => void onSaveInstructions('commitMessage')}
disabled={!isCommitInstructionsDirty || isSavingInstructions}
onClick={() => void onSavePrompt('commitMessage')}
disabled={!isCommitPromptDirty || isSavingPrompt}
>
{isSavingInstructions ? 'Saving...' : 'Save'}
{isSavingPrompt ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
@ -1225,50 +1180,50 @@ export function CommitMessageAiPane({
}
if (
(config.enabled || isPullRequestInstructionsDirty) &&
(isPullRequestInstructionsDirty ||
(config.enabled || isPullRequestPromptDirty) &&
(isPullRequestPromptDirty ||
matchesSettingsSearch(searchQuery, {
title: 'Pull request instructions',
description: 'Optional instructions appended only to pull-request detail prompts.',
keywords: ['prompt', 'instructions', 'pull request', 'pr', 'description', 'template']
title: 'Pull request prompt',
description: 'Additional prompt text appended only when generating pull request details.',
keywords: ['prompt', 'pull request', 'pr', 'description', 'template']
}))
) {
sections.push(
<SearchableSetting
key="pull-request-instructions"
title="Pull request instructions"
description="Optional instructions appended only to pull-request detail prompts."
keywords={['prompt', 'instructions', 'pull request', 'pr', 'description', 'template']}
forceVisible={isPullRequestInstructionsDirty}
key="pull-request-prompt"
title="Pull request prompt"
description="Additional prompt text appended only when generating pull request details."
keywords={['prompt', 'pull request', 'pr', 'description', 'template']}
forceVisible={isPullRequestPromptDirty}
className="space-y-2 px-1 py-2"
>
<div className="space-y-0.5">
<Label htmlFor="source-control-ai-pr-instructions">Pull request instructions</Label>
<Label htmlFor="source-control-ai-pr-prompt">Pull request prompt</Label>
<p className="text-xs text-muted-foreground">
Appended only when generating pull request titles, descriptions, draft state, and base
suggestions. These instructions never affect commit messages.
This prompt is appended only when generating pull request titles, descriptions, draft
state, and base suggestions. It never affects commit messages.
</p>
</div>
<textarea
id="source-control-ai-pr-instructions"
id="source-control-ai-pr-prompt"
rows={4}
value={pullRequestInstructionsDraft}
onChange={(e) => setPullRequestInstructionsDraft(e.target.value)}
value={pullRequestPromptDraft}
onChange={(e) => setPullRequestPromptDraft(e.target.value)}
placeholder="Summarize user-visible changes first, then list reviewer notes and testing evidence."
className="w-full resize-y rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring"
/>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-muted-foreground">
{isPullRequestInstructionsDirty ? 'Unsaved changes' : 'Saved'}
{isPullRequestPromptDirty ? 'Unsaved changes' : 'Saved'}
</p>
<div className="flex items-center gap-2">
{isPullRequestInstructionsDirty ? (
{isPullRequestPromptDirty ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onDiscardInstructions('pullRequest')}
disabled={isSavingInstructions}
onClick={() => onDiscardPrompt('pullRequest')}
disabled={isSavingPrompt}
>
Discard
</Button>
@ -1277,76 +1232,10 @@ export function CommitMessageAiPane({
type="button"
variant="secondary"
size="xs"
onClick={() => void onSaveInstructions('pullRequest')}
disabled={!isPullRequestInstructionsDirty || isSavingInstructions}
onClick={() => void onSavePrompt('pullRequest')}
disabled={!isPullRequestPromptDirty || isSavingPrompt}
>
{isSavingInstructions ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
</SearchableSetting>
)
}
if (
(config.enabled || isBranchNameInstructionsDirty) &&
(isBranchNameInstructionsDirty ||
matchesSettingsSearch(searchQuery, {
title: 'Branch name instructions',
description: 'Optional instructions appended only to auto branch-name prompts.',
keywords: ['prompt', 'instructions', 'branch', 'branch name', 'rename', 'slug']
}))
) {
sections.push(
<SearchableSetting
key="branch-name-instructions"
title="Branch name instructions"
description="Optional instructions appended only to auto branch-name prompts."
keywords={['prompt', 'instructions', 'branch', 'branch name', 'rename', 'slug']}
forceVisible={isBranchNameInstructionsDirty}
className="space-y-2 px-1 py-2"
>
<div className="space-y-0.5">
<Label htmlFor="source-control-ai-branch-name-instructions">
Branch name instructions
</Label>
<p className="text-xs text-muted-foreground">
Appended only when Auto-Rename Branch From Work summarizes the first agent prompt.
Output guardrails still force a short kebab-case branch leaf.
</p>
</div>
<textarea
id="source-control-ai-branch-name-instructions"
rows={4}
value={branchNameInstructionsDraft}
onChange={(e) => setBranchNameInstructionsDraft(e.target.value)}
placeholder="Prefer domain nouns from the task, avoid ticket IDs, and keep names reviewer-friendly."
className="w-full resize-y rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring"
/>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-muted-foreground">
{isBranchNameInstructionsDirty ? 'Unsaved changes' : 'Saved'}
</p>
<div className="flex items-center gap-2">
{isBranchNameInstructionsDirty ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onDiscardInstructions('branchName')}
disabled={isSavingInstructions}
>
Discard
</Button>
) : null}
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => void onSaveInstructions('branchName')}
disabled={!isBranchNameInstructionsDirty || isSavingInstructions}
>
{isSavingInstructions ? 'Saving...' : 'Save'}
{isSavingPrompt ? 'Saving...' : 'Save'}
</Button>
</div>
</div>

View File

@ -0,0 +1,56 @@
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { useAppStore } from '../../store'
import { shouldOpenAutoRenameBranchAdvanced } from './AutoRenameBranchFromWorkSetting'
import { GitPane, shouldShowAutoRenameBranchSetting } from './GitPane'
function renderGitPane(searchQuery: string): string {
useAppStore.setState({ settingsSearchQuery: searchQuery })
return renderToStaticMarkup(
React.createElement(GitPane, {
settings: getDefaultSettings('/tmp'),
updateSettings: () => {},
writeSourceControlAiSettings: async () => {},
displayedGitUsername: 'brennan',
settingsSearchQuery: searchQuery
})
)
}
describe('GitPane', () => {
it('keeps the auto-rename branch setting visible while its prompt draft is dirty', () => {
expect(shouldShowAutoRenameBranchSetting('zz-no-match', true)).toBe(true)
})
it('shows the auto-rename branch setting for advanced prompt and model searches', () => {
expect(shouldShowAutoRenameBranchSetting('instructions', false)).toBe(true)
expect(shouldShowAutoRenameBranchSetting('built-in prompt', false)).toBe(true)
expect(shouldShowAutoRenameBranchSetting('thinking', false)).toBe(true)
expect(shouldShowAutoRenameBranchSetting('override', false)).toBe(true)
})
it('hides the auto-rename branch setting when search misses and the prompt draft is clean', () => {
expect(shouldShowAutoRenameBranchSetting('zz-no-match', false)).toBe(false)
})
it('opens auto-rename advanced controls when search matches hidden prompt or model fields', () => {
expect(shouldOpenAutoRenameBranchAdvanced('prompt')).toBe(true)
expect(shouldOpenAutoRenameBranchAdvanced('model')).toBe(true)
expect(shouldOpenAutoRenameBranchAdvanced('instructions')).toBe(true)
expect(shouldOpenAutoRenameBranchAdvanced('built-in prompt')).toBe(true)
expect(shouldOpenAutoRenameBranchAdvanced('thinking')).toBe(true)
expect(shouldOpenAutoRenameBranchAdvanced('override')).toBe(true)
})
it('renders auto-rename advanced controls for advanced-only search terms', () => {
expect(renderGitPane('instructions')).toContain('Branch name prompt')
expect(renderGitPane('thinking')).toContain('Branch name model')
})
it('keeps auto-rename advanced controls collapsed without an advanced search match', () => {
expect(shouldOpenAutoRenameBranchAdvanced('')).toBe(false)
expect(shouldOpenAutoRenameBranchAdvanced('creature name')).toBe(false)
})
})

View File

@ -1,4 +1,5 @@
import type { GlobalSettings } from '../../../../shared/types'
import type { SourceControlAiSettingsPatch } from '../../../../shared/source-control-ai-types'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { useAppStore } from '../../store'
@ -6,21 +7,44 @@ import { GIT_PANE_SEARCH_ENTRIES } from './git-search'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch } from './settings-search'
import { GitHubRateLimitPanel } from '../github/github-rate-limit-display'
import { AutoRenameBranchFromWorkSetting } from './AutoRenameBranchFromWorkSetting'
import { AUTO_RENAME_BRANCH_SEARCH_ENTRIES } from './auto-rename-branch-search'
export { GIT_PANE_SEARCH_ENTRIES }
export function shouldShowAutoRenameBranchSetting(
searchQuery: string,
hasUnsavedBranchPromptChanges: boolean
): boolean {
return (
hasUnsavedBranchPromptChanges ||
matchesSettingsSearch(searchQuery, AUTO_RENAME_BRANCH_SEARCH_ENTRIES)
)
}
type GitPaneProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
updateSettings: (updates: Partial<GlobalSettings>) => void | Promise<void>
writeSourceControlAiSettings: (patch: SourceControlAiSettingsPatch) => Promise<void>
displayedGitUsername: string
hasUnsavedBranchPromptChanges?: boolean
onBranchPromptDirtyChange?: (dirty: boolean) => void
branchPromptDiscardSignal?: number
settingsSearchQuery?: string
}
export function GitPane({
settings,
updateSettings,
displayedGitUsername
writeSourceControlAiSettings,
displayedGitUsername,
hasUnsavedBranchPromptChanges = false,
onBranchPromptDirtyChange,
branchPromptDiscardSignal,
settingsSearchQuery
}: GitPaneProps): React.JSX.Element {
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
const storeSearchQuery = useAppStore((s) => s.settingsSearchQuery)
const searchQuery = settingsSearchQuery ?? storeSearchQuery
const visibleSections = [
matchesSettingsSearch(searchQuery, {
@ -35,6 +59,12 @@ export function GitPane({
keywords={['branch naming', 'git username', 'custom']}
className="space-y-3"
>
<div className="space-y-0.5">
<Label>Branch Prefix</Label>
<p className="text-xs text-muted-foreground">
Choose whether branch names use your Git username, a custom prefix, or no prefix.
</p>
</div>
<div className="flex w-fit gap-1 rounded-md border border-border/50 p-1">
{(['git-username', 'custom', 'none'] as const).map((option) => (
<button
@ -131,46 +161,17 @@ export function GitPane({
</button>
</SearchableSetting>
) : null,
matchesSettingsSearch(searchQuery, {
title: 'Auto-Rename Branch From Work',
description: 'Rename the auto-generated branch based on the work once an agent starts.',
keywords: ['branch', 'rename', 'auto', 'creature name', 'agent', 'prompt', 'worktree']
}) ? (
<SearchableSetting
shouldShowAutoRenameBranchSetting(searchQuery, hasUnsavedBranchPromptChanges) ? (
<AutoRenameBranchFromWorkSetting
key="auto-rename-branch-from-work"
title="Auto-Rename Branch From Work"
description="Rename the auto-generated branch based on the work once an agent starts."
keywords={['branch', 'rename', 'auto', 'creature name', 'agent', 'prompt', 'worktree']}
className="flex items-center justify-between gap-4 py-2"
>
<div className="space-y-0.5">
<Label>Auto-Rename Branch From Work</Label>
<p className="text-xs text-muted-foreground">
When an agent starts working in a new workspace, Orca renames its auto-generated branch
(e.g. <code>Nautilus</code>) to a short name summarizing the task. Only branches Orca
named itself are renamed, and never after they have been pushed. Uses the agent
configured for AI commit messages.
</p>
</div>
<button
role="switch"
aria-checked={settings.autoRenameBranchFromWork}
onClick={() =>
updateSettings({
autoRenameBranchFromWork: !settings.autoRenameBranchFromWork
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.autoRenameBranchFromWork ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.autoRenameBranchFromWork ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
settings={settings}
updateSettings={updateSettings}
writeSourceControlAiSettings={writeSourceControlAiSettings}
forceVisible={hasUnsavedBranchPromptChanges}
onBranchPromptDirtyChange={onBranchPromptDirtyChange}
branchPromptDiscardSignal={branchPromptDiscardSignal}
settingsSearchQuery={searchQuery}
/>
) : null,
matchesSettingsSearch(searchQuery, {
title: 'GitHub API Budget',

View File

@ -48,20 +48,20 @@ const OPERATIONS: {
{
operation: 'commitMessage',
modelLabel: 'Commit message model',
instructionLabel: 'Commit message instructions',
globalPlaceholder: 'Global commit message instructions are empty.'
instructionLabel: 'Commit message prompt',
globalPlaceholder: 'Global commit message prompt is empty.'
},
{
operation: 'pullRequest',
modelLabel: 'PR details model',
instructionLabel: 'Pull request instructions',
globalPlaceholder: 'Global pull request instructions are empty.'
instructionLabel: 'Pull request prompt',
globalPlaceholder: 'Global pull request prompt is empty.'
},
{
operation: 'branchName',
modelLabel: 'Branch name model',
instructionLabel: 'Branch name instructions',
globalPlaceholder: 'Global branch name instructions are empty.'
instructionLabel: 'Branch name prompt',
globalPlaceholder: 'Global branch name prompt is empty.'
}
]
@ -72,11 +72,11 @@ type RepoAiDraftState = {
baseSerialized: string
}
function hasOwnInstruction(
instructions: RepoSourceControlAiOverrides['instructionsByOperation'],
function hasOwnPrompt(
prompts: RepoSourceControlAiOverrides['instructionsByOperation'],
operation: SourceControlAiOperation
): boolean {
return typeof instructions?.[operation] === 'string'
return typeof prompts?.[operation] === 'string'
}
function triStateValue(value: boolean | null | undefined): 'inherit' | 'on' | 'off' {
@ -279,13 +279,13 @@ export function RepositorySourceControlAiSection({
inheritedValue: string
): void => {
updateDraftRepoAi((current) => {
const nextInstructions = { ...current.instructionsByOperation }
const nextPrompts = { ...current.instructionsByOperation }
if (mode === PROMPT_MODE_INHERIT) {
delete nextInstructions[operation]
} else if (!hasOwnInstruction(nextInstructions, operation)) {
nextInstructions[operation] = inheritedValue
delete nextPrompts[operation]
} else if (!hasOwnPrompt(nextPrompts, operation)) {
nextPrompts[operation] = inheritedValue
}
return { ...current, instructionsByOperation: nextInstructions }
return { ...current, instructionsByOperation: nextPrompts }
})
}
@ -459,7 +459,7 @@ export function RepositorySourceControlAiSection({
<div className="space-y-3">
{OPERATIONS.map((row) => {
const inherited = source.instructionsByOperation[row.operation]?.trim() ?? ''
const hasOverride = hasOwnInstruction(repoAi.instructionsByOperation, row.operation)
const hasOverride = hasOwnPrompt(repoAi.instructionsByOperation, row.operation)
const value = hasOverride ? (repoAi.instructionsByOperation?.[row.operation] ?? '') : ''
return (
<div key={row.instructionLabel} className="space-y-2">

View File

@ -2,7 +2,12 @@
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'
import { toast } from 'sonner'
import { Info } from 'lucide-react'
import type { OrcaHooks } from '../../../../shared/types'
import type { GlobalSettings, OrcaHooks } from '../../../../shared/types'
import type {
SourceControlAiSettings,
SourceControlAiSettingsPatch
} from '../../../../shared/source-control-ai-types'
import { normalizeSourceControlAiSettings } from '../../../../shared/source-control-ai'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { useAppStore } from '../../store'
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
@ -133,6 +138,10 @@ function scrollSubsectionIntoView(targetId: string, container?: HTMLElement | nu
container.scrollTo({ top: Math.min(Math.max(0, targetTop - 16), maxScrollTop) })
}
function readSourceControlAiSettings(settings: GlobalSettings): SourceControlAiSettings {
return normalizeSourceControlAiSettings(settings.sourceControlAi, settings.commitMessageAi)
}
function cancelPendingSettingsSubsectionScrollFrame(
frameRef: MutableRefObject<number | null>
): void {
@ -199,7 +208,8 @@ function Settings(): React.JSX.Element {
const [pendingNavRequestTick, setPendingNavRequestTick] = useState(0)
const [quickCommandAddIntentSignal, setQuickCommandAddIntentSignal] = useState(0)
const [hasUnsavedCommitPromptChanges, setHasUnsavedCommitPromptChanges] = useState(false)
const [commitPromptDiscardSignal, setCommitPromptDiscardSignal] = useState(0)
const [hasUnsavedBranchPromptChanges, setHasUnsavedBranchPromptChanges] = useState(false)
const [sourceControlAiPromptDiscardSignal, setSourceControlAiPromptDiscardSignal] = useState(0)
const confirm = useConfirmationDialog()
// Why: the hidden-experimental group is an unlock — Shift-clicking the
// Experimental sidebar entry reveals it for the remainder of the session.
@ -215,6 +225,29 @@ function Settings(): React.JSX.Element {
const repoHooksRequestSeqRef = useRef(0)
const repoHooksRuntimeIdentityRef = useRef<string>('local')
const shortcutsEscapeConfirmUntilRef = useRef(0)
const sourceControlAiWriteQueueRef = useRef<Promise<void>>(Promise.resolve())
const hasUnsavedSourceControlAiPromptChanges =
hasUnsavedCommitPromptChanges || hasUnsavedBranchPromptChanges
const writeSourceControlAiSettings = useCallback(
(patch: SourceControlAiSettingsPatch): Promise<void> => {
const next = sourceControlAiWriteQueueRef.current
.catch(() => undefined)
.then(async () => {
const latestSettings = useAppStore.getState().settings ?? settings
if (!latestSettings) {
return
}
const latestConfig = readSourceControlAiSettings(latestSettings)
const resolvedPatch = typeof patch === 'function' ? patch(latestConfig) : patch
await updateSettings({ sourceControlAi: { ...latestConfig, ...resolvedPatch } })
})
sourceControlAiWriteQueueRef.current = next
return next
},
[settings, updateSettings]
)
const setSettingsRootNode = useCallback(
(node: HTMLDivElement | null): void => {
@ -228,8 +261,8 @@ function Settings(): React.JSX.Element {
[setSettingsSearchQuery]
)
const confirmDiscardCommitPromptChanges = useCallback(async (): Promise<boolean> => {
if (!hasUnsavedCommitPromptChanges) {
const confirmDiscardSourceControlAiPromptChanges = useCallback(async (): Promise<boolean> => {
if (!hasUnsavedSourceControlAiPromptChanges) {
return true
}
const shouldDiscard = await confirm({
@ -239,18 +272,19 @@ function Settings(): React.JSX.Element {
confirmVariant: 'destructive'
})
if (shouldDiscard) {
setCommitPromptDiscardSignal((signal) => signal + 1)
setSourceControlAiPromptDiscardSignal((signal) => signal + 1)
setHasUnsavedCommitPromptChanges(false)
setHasUnsavedBranchPromptChanges(false)
}
return shouldDiscard
}, [confirm, hasUnsavedCommitPromptChanges])
}, [confirm, hasUnsavedSourceControlAiPromptChanges])
const closeSettingsPageWithPromptGuard = useCallback(async (): Promise<void> => {
if (!(await confirmDiscardCommitPromptChanges())) {
if (!(await confirmDiscardSourceControlAiPromptChanges())) {
return
}
closeSettingsPage()
}, [closeSettingsPage, confirmDiscardCommitPromptChanges])
}, [closeSettingsPage, confirmDiscardSourceControlAiPromptChanges])
useEffect(() => {
fetchSettings()
@ -324,14 +358,14 @@ function Settings(): React.JSX.Element {
if (isIntentionalAppRestartInProgress()) {
return
}
if (!hasUnsavedCommitPromptChanges) {
if (!hasUnsavedSourceControlAiPromptChanges) {
return
}
event.preventDefault()
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [hasUnsavedCommitPromptChanges])
}, [hasUnsavedSourceControlAiPromptChanges])
useEffect(() => {
const handleFindShortcut = (event: KeyboardEvent): void => {
@ -410,14 +444,14 @@ function Settings(): React.JSX.Element {
const visibleNavSections = useMemo(
() =>
navSections.filter((section) =>
section.id === 'git' && hasUnsavedCommitPromptChanges
section.id === 'git' && hasUnsavedSourceControlAiPromptChanges
? true
: matchesSettingsSearch(settingsSearchQuery, [
{ title: section.title, description: section.description },
...section.searchEntries
])
),
[hasUnsavedCommitPromptChanges, navSections, settingsSearchQuery]
[hasUnsavedSourceControlAiPromptChanges, navSections, settingsSearchQuery]
)
const visibleSectionIds = useMemo(
() => new Set(visibleNavSections.map((section) => section.id)),
@ -656,7 +690,7 @@ function Settings(): React.JSX.Element {
sectionId: string,
modifiers?: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }
): Promise<void> => {
if (sectionId !== activeSectionId && !(await confirmDiscardCommitPromptChanges())) {
if (sectionId !== activeSectionId && !(await confirmDiscardSourceControlAiPromptChanges())) {
return
}
// Why: Shift-clicking the Experimental sidebar entry unlocks a hidden
@ -681,14 +715,14 @@ function Settings(): React.JSX.Element {
},
[
activeSectionId,
confirmDiscardCommitPromptChanges,
confirmDiscardSourceControlAiPromptChanges,
setSettingsSearchQuery,
settingsSearchQuery
]
)
const openComputerUseFromBrowser = useCallback(async () => {
if (!(await confirmDiscardCommitPromptChanges())) {
if (!(await confirmDiscardSourceControlAiPromptChanges())) {
return
}
pendingNavSectionRef.current = 'computer-use'
@ -700,7 +734,7 @@ function Settings(): React.JSX.Element {
// Why: the pending section refs do not schedule a render by themselves.
// When search is already clear, this reruns the centralized jump effect.
setPendingNavRequestTick((tick) => tick + 1)
}, [confirmDiscardCommitPromptChanges, setSettingsSearchQuery, settingsSearchQuery])
}, [confirmDiscardSourceControlAiPromptChanges, setSettingsSearchQuery, settingsSearchQuery])
if (!settings) {
return (
@ -890,20 +924,25 @@ function Settings(): React.JSX.Element {
title="Git & Source Control"
description="Branch naming, base refs, attribution, and Source Control AI."
searchEntries={getSectionSearchEntries('git')}
forceVisible={hasUnsavedCommitPromptChanges}
forceVisible={hasUnsavedSourceControlAiPromptChanges}
>
{isSectionMounted('git') ? (
<>
<GitPane
settings={settings}
updateSettings={updateSettings}
writeSourceControlAiSettings={writeSourceControlAiSettings}
displayedGitUsername={displayedGitUsername}
hasUnsavedBranchPromptChanges={hasUnsavedBranchPromptChanges}
onBranchPromptDirtyChange={setHasUnsavedBranchPromptChanges}
branchPromptDiscardSignal={sourceControlAiPromptDiscardSignal}
/>
<CommitMessageAiPane
settings={settings}
updateSettings={updateSettings}
writeSourceControlAiSettings={writeSourceControlAiSettings}
onCustomPromptDirtyChange={setHasUnsavedCommitPromptChanges}
customPromptDiscardSignal={commitPromptDiscardSignal}
customPromptDiscardSignal={sourceControlAiPromptDiscardSignal}
/>
</>
) : null}

View File

@ -0,0 +1,35 @@
import type { SettingsSearchEntry } from './settings-search'
export const AUTO_RENAME_BRANCH_PARENT_SEARCH_ENTRY: SettingsSearchEntry = {
title: 'Auto-Rename Branch',
description: 'Rename the auto-generated branch based on the work once an agent starts.',
keywords: [
'branch',
'rename',
'auto',
'creature name',
'agent',
'prompt',
'worktree',
'model',
'slug'
]
}
export const AUTO_RENAME_BRANCH_ADVANCED_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Branch name prompt',
description: 'Additional prompt text appended only when generating branch names.',
keywords: ['prompt', 'instructions', 'built-in prompt', 'slug', 'kebab-case']
},
{
title: 'Branch name model',
description: 'Use a different model for branch name generation.',
keywords: ['model', 'override', 'thinking']
}
]
export const AUTO_RENAME_BRANCH_SEARCH_ENTRIES: SettingsSearchEntry[] = [
AUTO_RENAME_BRANCH_PARENT_SEARCH_ENTRY,
...AUTO_RENAME_BRANCH_ADVANCED_SEARCH_ENTRIES
]

View File

@ -34,24 +34,18 @@ export const COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
},
{
title: 'Advanced model overrides',
description:
'Optional per-operation model choices for commit messages, PR details, and branch names.',
keywords: ['model', 'override', 'commit', 'pull request', 'pr', 'branch', 'thinking']
description: 'Optional per-operation model choices for commit messages and PR details.',
keywords: ['model', 'override', 'commit', 'pull request', 'pr', 'thinking']
},
{
title: 'Commit message instructions',
description: 'Optional instructions appended only to commit-message prompts.',
keywords: ['prompt', 'instructions', 'conventional commits', 'gitmoji', 'style']
title: 'Commit message prompt',
description: 'Additional prompt text appended only when generating commit messages.',
keywords: ['prompt', 'conventional commits', 'gitmoji', 'style']
},
{
title: 'Pull request instructions',
description: 'Optional instructions appended only to pull-request detail prompts.',
keywords: ['prompt', 'instructions', 'pull request', 'pr', 'description', 'template']
},
{
title: 'Branch name instructions',
description: 'Optional instructions appended only to auto branch-name prompts.',
keywords: ['prompt', 'instructions', 'branch', 'branch name', 'rename', 'slug']
title: 'Pull request prompt',
description: 'Additional prompt text appended only when generating pull request details.',
keywords: ['prompt', 'pull request', 'pr', 'description', 'template']
},
{
title: 'PR creation defaults',

View File

@ -1,4 +1,5 @@
import type { SettingsSearchEntry } from './settings-search'
import { AUTO_RENAME_BRANCH_SEARCH_ENTRIES } from './auto-rename-branch-search'
export const GIT_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
@ -20,11 +21,7 @@ export const GIT_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'worktree'
]
},
{
title: 'Auto-Rename Branch From Work',
description: 'Rename the auto-generated branch based on the work once an agent starts.',
keywords: ['branch', 'rename', 'auto', 'creature name', 'agent', 'prompt', 'worktree']
},
...AUTO_RENAME_BRANCH_SEARCH_ENTRIES,
{
title: 'GitHub API Budget',
description: 'Current GitHub CLI REST, Search, and GraphQL rate limits.',

View File

@ -68,8 +68,7 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[
'branch name',
'rename',
'model',
'prompt',
'instructions'
'prompt'
]
},
{

View File

@ -74,12 +74,12 @@ describe('buildBranchNamePrompt', () => {
expect(prompt).toContain("I'll wire it into the header.")
})
it('appends custom branch-name instructions when present', () => {
it('appends a custom branch-name prompt when present', () => {
const prompt = buildBranchNamePrompt(
{ firstPrompt: 'Add a logout button' },
'Prefer product nouns.'
)
expect(prompt).toContain('Additional user instructions:')
expect(prompt).toContain('Additional user prompt:')
expect(prompt).toContain('Prefer product nouns.')
})
})

View File

@ -71,10 +71,7 @@ export type BranchNameWorkContext = {
* the work into a branch name. Kept in shared so the prompt is identical across
* local and SSH generation targets.
*/
export function buildBranchNamePrompt(
context: BranchNameWorkContext,
customInstructions = ''
): string {
export function buildBranchNamePrompt(context: BranchNameWorkContext, customPrompt = ''): string {
const sections = [
'Generate a git branch name that summarizes the coding task described below.',
'Rules:',
@ -91,9 +88,9 @@ export function buildBranchNamePrompt(
if (assistant) {
sections.push('', "Agent's initial response:", assistant)
}
const instructions = customInstructions.trim()
if (instructions) {
sections.push('', 'Additional user instructions:', instructions)
const prompt = customPrompt.trim()
if (prompt) {
sections.push('', 'Additional user prompt:', prompt)
}
return sections.join('\n')
}

View File

@ -17,10 +17,10 @@ describe('buildCommitMessagePrompt', () => {
expect(prompt).toContain('Staged patch:\n```diff')
expect(prompt).toContain('+hello')
expect(prompt).toContain('Use only the staged changes below as context.')
expect(prompt).not.toContain('Additional instructions from user:')
expect(prompt).not.toContain('Additional user prompt:')
})
it('keeps custom instructions in a separate bounded section', () => {
it('keeps a custom prompt in a separate bounded section', () => {
const prompt = buildCommitMessagePrompt(
{
branch: null,
@ -31,7 +31,7 @@ describe('buildCommitMessagePrompt', () => {
)
expect(prompt).toContain('Branch: (detached)')
expect(prompt).toContain('Additional instructions from user:\nUse Conventional Commits.')
expect(prompt).toContain('Additional user prompt:\nUse Conventional Commits.')
})
})

View File

@ -33,7 +33,7 @@ function limitSection(value: string, maxChars: number): string {
export function buildCommitMessagePrompt(
context: CommitMessageDraftContext,
customInstructions: string
customPrompt: string
): string {
const patch = truncateDiffForPrompt(context.stagedPatch)
const base = [
@ -58,16 +58,11 @@ export function buildCommitMessagePrompt(
'```'
].join('\n')
const trimmedInstructions = customInstructions.trim()
if (!trimmedInstructions) {
const trimmedPrompt = customPrompt.trim()
if (!trimmedPrompt) {
return base
}
return [
base,
'',
'Additional instructions from user:',
limitSection(trimmedInstructions, 4_000)
].join('\n')
return [base, '', 'Additional user prompt:', limitSection(trimmedPrompt, 4_000)].join('\n')
}
export function splitGeneratedCommitMessage(message: string): GeneratedCommitMessage {

View File

@ -19,13 +19,13 @@ describe('buildCommitPrompt', () => {
it('appends a custom suffix when non-empty', () => {
const prompt = buildCommitPrompt('diff', 'Use Conventional Commits.')
expect(prompt).toContain('Additional instructions from user:')
expect(prompt).toContain('Additional user prompt:')
expect(prompt.endsWith('Use Conventional Commits.')).toBe(true)
})
it('does not append the suffix block for whitespace-only suffixes', () => {
const prompt = buildCommitPrompt('diff', ' \n ')
expect(prompt).not.toContain('Additional instructions from user:')
expect(prompt).not.toContain('Additional user prompt:')
})
})

View File

@ -25,7 +25,7 @@ export function buildCommitPrompt(diff: string, customSuffix: string): string {
if (!trimmedSuffix) {
return base
}
return `${base}\n\nAdditional instructions from user:\n${trimmedSuffix}`
return `${base}\n\nAdditional user prompt:\n${trimmedSuffix}`
}
export const STAGED_DIFF_BYTE_BUDGET = 200_000

View File

@ -10,6 +10,10 @@ describe('getDefaultSettings', () => {
expect(getDefaultSettings('/tmp').sourceControlViewMode).toBe('list')
})
it('keeps first-work branch auto-renaming off by default for new settings', () => {
expect(getDefaultSettings('/tmp').autoRenameBranchFromWork).toBe(false)
})
it('enables separate light terminal theme by default', () => {
expect(getDefaultSettings('/tmp').terminalUseSeparateLightTheme).toBe(true)
})

View File

@ -24,7 +24,7 @@ describe('buildPullRequestFieldsPrompt', () => {
expect(prompt).toContain('Return ONLY compact JSON')
expect(prompt).toContain('Head branch: feature/pr-details')
expect(prompt).toContain('Current base: main')
expect(prompt).toContain('Additional instructions from user:')
expect(prompt).toContain('Additional user prompt:')
expect(prompt).toContain('Use conventional PR titles.')
})
})

View File

@ -29,7 +29,7 @@ function limitSection(value: string, maxChars: number): string {
export function buildPullRequestFieldsPrompt(
context: PullRequestDraftContext,
customInstructions: string
customPrompt: string
): string {
const base = [
'You are generating pull request details.',
@ -62,8 +62,8 @@ export function buildPullRequestFieldsPrompt(
'```'
].join('\n')
const trimmedInstructions = customInstructions.trim()
if (!trimmedInstructions) {
const trimmedPrompt = customPrompt.trim()
if (!trimmedPrompt) {
return [
base,
'',
@ -74,8 +74,8 @@ export function buildPullRequestFieldsPrompt(
return [
base,
'',
'Additional instructions from user:',
limitSection(trimmedInstructions, 4_000),
'Additional user prompt:',
limitSection(trimmedPrompt, 4_000),
'',
'Final output requirement:',
'Return compact JSON only with keys base, title, body, and draft. No prose or code fences.'

View File

@ -31,6 +31,10 @@ export type SourceControlAiSettings = {
prCreationDefaults?: SourceControlAiPrCreationDefaults
}
export type SourceControlAiSettingsPatch =
| Partial<SourceControlAiSettings>
| ((current: SourceControlAiSettings) => Partial<SourceControlAiSettings>)
export type RepoSourceControlAiOverrides = {
modelOverridesByOperation?: Partial<Record<SourceControlAiOperation, SourceControlAiModelChoice>>
instructionsByOperation?: Partial<Record<SourceControlAiOperation, string | null>>