Clean up auto branch rename settings (#3378)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
4ea2eae432
commit
e4ac0df51d
|
|
@ -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.')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -68,8 +68,7 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[
|
|||
'branch name',
|
||||
'rename',
|
||||
'model',
|
||||
'prompt',
|
||||
'instructions'
|
||||
'prompt'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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>>
|
||||
|
|
|
|||
Loading…
Reference in New Issue