Warn about and manage repository overrides for AI action recipes

This improves the user experience around overriding global AI action recipe
defaults with repository-specific settings:
- In global settings, display a note if repositories override a recipe,
  with a shortcut to review those repository settings.
- In the agent launch dialog, show a warning if the repo overrides the
  global default and default the save target to the repo.
- In repository settings, support saving and discarding recipe overrides
  on a per-action basis.
This commit is contained in:
Jinjing
2026-07-04 17:46:27 -07:00
parent a074ed6182
commit 71b9211f0b
22 changed files with 1055 additions and 95 deletions
@@ -65,6 +65,7 @@ export function SourceControlAgentActionDialog(
const {
handleOpenChange,
shouldRenderDialog,
agentScopeNote,
agentOptions,
selectedAgent,
hasEnabledAgents,
@@ -101,6 +102,7 @@ export function SourceControlAgentActionDialog(
<SourceControlAgentActionDialogForm
actionId={actionId}
baseCommandInput={baseCommandInput}
agentScopeNote={agentScopeNote}
agentOptions={agentOptions}
selectedAgent={selectedAgent}
hasEnabledAgents={hasEnabledAgents}
@@ -43,6 +43,7 @@ function renderForm(
React.createElement(SourceControlAgentActionDialogForm, {
actionId: 'resolveConflicts',
baseCommandInput: 'Resolve the merge conflicts reported for this pull request.',
agentScopeNote: null,
agentOptions: [],
selectedAgent: 'codex',
hasEnabledAgents: true,
@@ -133,4 +134,28 @@ describe('SourceControlAgentActionDialogForm', () => {
expect(repoMarkup).not.toContain('Launch recipe already saved')
expect(repoMarkup).toContain('Save &amp; start agent')
})
it('surfaces a diverging repo override alongside the save controls', () => {
const markup = renderForm({
agentScopeNote: { effectiveAgentLabel: 'Codex', globalAgentLabel: 'Claude' }
})
expect(markup).toContain('overrides your global default (Claude)')
expect(markup).toContain('currently runs Codex')
})
it('omits the scope note when there is no diverging repo override', () => {
const markup = renderForm({ agentScopeNote: null })
expect(markup).not.toContain('overrides your global default')
})
it('omits the scope note when the save controls are hidden', () => {
const markup = renderForm({
agentScopeNote: { effectiveAgentLabel: 'Codex', globalAgentLabel: 'Claude' },
canSaveAgentDefault: false
})
expect(markup).not.toContain('overrides your global default')
})
})
@@ -1,5 +1,13 @@
import React from 'react'
import { CheckCircle2, RefreshCw, RotateCcw, Settings, Sparkles, TriangleAlert } from 'lucide-react'
import {
CheckCircle2,
Info,
RefreshCw,
RotateCcw,
Settings,
Sparkles,
TriangleAlert
} from 'lucide-react'
import AgentCombobox from '@/components/agent/AgentCombobox'
import { Button } from '@/components/ui/button'
import { DialogFooter } from '@/components/ui/dialog'
@@ -19,6 +27,7 @@ import type { SourceControlAiWriteTarget } from '../../../../shared/source-contr
import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types'
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match'
import type { SourceControlAgentScopeNote } from './source-control-agent-action-dialog-result'
import { translate } from '@/i18n/i18n'
export type SourceControlAgentActionDeliveryPlanState =
@@ -29,6 +38,7 @@ export type SourceControlAgentActionDeliveryPlanState =
type SourceControlAgentActionDialogFormProps = {
actionId: SourceControlLaunchActionId
baseCommandInput: string
agentScopeNote: SourceControlAgentScopeNote | null
agentOptions: AgentCatalogEntry[]
selectedAgent: TuiAgent | null
hasEnabledAgents: boolean
@@ -73,6 +83,7 @@ function sourceControlLaunchSaveTargetFromValue(
export function SourceControlAgentActionDialogForm({
actionId,
baseCommandInput,
agentScopeNote,
agentOptions,
selectedAgent,
hasEnabledAgents,
@@ -263,6 +274,22 @@ export function SourceControlAgentActionDialogForm({
) : null}
</div>
{showSaveLaunchRecipe && agentScopeNote ? (
<div className="flex items-start gap-1.5 rounded-md border border-border bg-muted/30 px-2.5 py-2 text-[11px] leading-4 text-muted-foreground">
<Info className="mt-px size-3 shrink-0" />
<span>
{translate(
'auto.components.right.sidebar.SourceControlAgentActionDialogForm.repoAgentOverrideNote',
'This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here.',
{
effective: agentScopeNote.effectiveAgentLabel,
global: agentScopeNote.globalAgentLabel
}
)}
</span>
</div>
) : null}
{showSaveLaunchRecipe ? (
<div
className={cn(
@@ -4,9 +4,15 @@ import type { useRepoById } from '@/store/selectors'
import type { TuiAgent } from '../../../../shared/types'
import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm'
export type SourceControlAgentScopeNote = {
effectiveAgentLabel: string
globalAgentLabel: string
}
export type UseSourceControlAgentActionDialogResult = {
handleOpenChange: (nextOpen: boolean) => void
shouldRenderDialog: boolean
agentScopeNote: SourceControlAgentScopeNote | null
agentOptions: ReturnType<typeof getAgentCatalog>
selectedAgent: TuiAgent | null
hasEnabledAgents: boolean
@@ -1,6 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getAgentCatalog } from '@/lib/agent-catalog'
import { pickSourceControlLaunchAgent } from '@/lib/source-control-launch-agent-selection'
import {
pickSourceControlLaunchAgent,
resolveSourceControlLaunchAgentScope
} from '@/lib/source-control-launch-agent-selection'
import { useAppStore } from '@/store'
import { useRepoById } from '@/store/selectors'
import { renderSourceControlActionCommandTemplate } from '../../../../shared/source-control-ai-actions'
@@ -39,6 +42,15 @@ export function useSourceControlAgentActionDialog({
}: SourceControlAgentActionDialogProps): UseSourceControlAgentActionDialogResult {
const settings = useAppStore((state) => state.settings)
const repo = useRepoById(repoId ?? null)
const launchAgentScope = useMemo(
() => resolveSourceControlLaunchAgentScope({ settings, repo, actionId }),
[actionId, repo, settings]
)
// Why: when this repo already overrides the global default, default the save
// scope to the repo so saving the corrected agent updates that override in
// place instead of writing a global default the override would still shadow.
const defaultSaveTargetValue =
launchAgentScope.overridesGlobalAgent && repoId ? 'repo' : DEFAULT_SAVE_TARGET_VALUE
const ensureDetectedAgents = useAppStore((state) => state.ensureDetectedAgents)
const ensureRemoteDetectedAgents = useAppStore((state) => state.ensureRemoteDetectedAgents)
const [commandTemplate, setCommandTemplate] = useState(
@@ -54,7 +66,7 @@ export function useSourceControlAgentActionDialog({
const [detectedOpenCycle, setDetectedOpenCycle] = useState<number | null>(null)
const saveTargets = useMemo(() => buildSourceControlAgentSaveTargets(repoId), [repoId])
const [saveLaunchRecipe, setSaveLaunchRecipe] = useState(true)
const [saveTargetValue, setSaveTargetValue] = useState(DEFAULT_SAVE_TARGET_VALUE)
const [saveTargetValue, setSaveTargetValue] = useState(defaultSaveTargetValue)
const disabledAgents = settings?.disabledTuiAgents
const connectionUnavailable = Boolean(worktreeId && connectionId === undefined)
@@ -94,7 +106,7 @@ export function useSourceControlAgentActionDialog({
setAgentArgs(savedAgentArgs ?? '')
setSelectedAgent(savedAgentId ?? null)
setSaveLaunchRecipe(true)
setSaveTargetValue(DEFAULT_SAVE_TARGET_VALUE)
setSaveTargetValue(defaultSaveTargetValue)
let stale = false
void refreshDetectedAgents().then((nextAgents) => {
if (stale || openCycleRef.current !== cycle) {
@@ -116,6 +128,7 @@ export function useSourceControlAgentActionDialog({
stale = true
}
}, [
defaultSaveTargetValue,
disabledAgents,
open,
refreshDetectedAgents,
@@ -191,11 +204,11 @@ export function useSourceControlAgentActionDialog({
if (!nextOpen) {
resetDeliveryPlan()
setSaveLaunchRecipe(true)
setSaveTargetValue(DEFAULT_SAVE_TARGET_VALUE)
setSaveTargetValue(defaultSaveTargetValue)
}
onOpenChange(nextOpen)
},
[onOpenChange, resetDeliveryPlan]
[defaultSaveTargetValue, onOpenChange, resetDeliveryPlan]
)
const { autoLaunchPending } = useSavedSourceControlAgentActionAutoStart({
@@ -263,9 +276,23 @@ export function useSourceControlAgentActionDialog({
[resetDeliveryPlan]
)
const agentScopeNote = useMemo(() => {
if (!launchAgentScope.overridesGlobalAgent) {
return null
}
const catalog = getAgentCatalog()
const labelFor = (agentId: TuiAgent | null): string =>
catalog.find((entry) => entry.id === agentId)?.label ?? agentId ?? ''
return {
effectiveAgentLabel: labelFor(launchAgentScope.effectiveAgentId),
globalAgentLabel: labelFor(launchAgentScope.globalAgentId)
}
}, [launchAgentScope])
return {
handleOpenChange,
shouldRenderDialog: !autoLaunchPending,
agentScopeNote,
agentOptions,
selectedAgent,
hasEnabledAgents,
@@ -13,6 +13,7 @@ import {
} from '../../../../shared/source-control-ai-actions'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Button } from '../ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { AgentIcon } from '@/lib/agent-catalog'
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
@@ -37,9 +38,11 @@ import {
resolveAgentArgsPlaceholderAgent
} from './repository-source-control-ai-labels'
import { hasOwnActionOverride } from './repository-source-control-ai-draft'
import { getRepositorySourceControlAiActionRecipeSectionId } from './repository-settings-targets'
import { translate } from '@/i18n/i18n'
type RepositorySourceControlAiActionRowsProps = {
repoId: string
repoAi: RepoSourceControlAiOverrides
source: SourceControlAiSettings
defaultTuiAgent: TuiAgent | 'blank' | null | undefined
@@ -48,9 +51,14 @@ type RepositorySourceControlAiActionRowsProps = {
onActionTemplateChange: (actionId: SourceControlActionId, value: string) => void
onActionAgentArgsChange: (actionId: SourceControlActionId, value: string) => void
onAppendVariable: (actionId: SourceControlActionId, variable: string) => void
isSaving: boolean
actionDirtyById: Record<SourceControlActionId, boolean>
onActionDiscard: (actionId: SourceControlActionId) => void
onActionSave: (actionId: SourceControlActionId) => void
}
export function RepositorySourceControlAiActionRows({
repoId,
repoAi,
source,
defaultTuiAgent,
@@ -58,7 +66,11 @@ export function RepositorySourceControlAiActionRows({
onActionAgentChange,
onActionTemplateChange,
onActionAgentArgsChange,
onAppendVariable
onAppendVariable,
isSaving,
actionDirtyById,
onActionDiscard,
onActionSave
}: RepositorySourceControlAiActionRowsProps): React.JSX.Element {
return (
<div className="space-y-3">
@@ -90,8 +102,17 @@ export function RepositorySourceControlAiActionRows({
const agentOptions = getAgentCatalogForAction(actionId, effectiveAgent)
const agentWarningText = getSourceControlActionAgentWarningText(actionId, effectiveAgent)
const agentSupportText = getSourceControlActionAgentSupportText(actionId)
const actionDirty = actionDirtyById[actionId]
return (
<div key={actionId} className="space-y-3 rounded-md border border-border px-3 py-3">
<div
key={actionId}
id={getRepositorySourceControlAiActionRecipeSectionId(repoId, actionId)}
data-settings-section={getRepositorySourceControlAiActionRecipeSectionId(
repoId,
actionId
)}
className="scroll-mt-8 space-y-3 rounded-md border border-border px-3 py-3"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-0.5">
<p className="text-xs font-medium text-foreground">
@@ -227,6 +248,54 @@ export function RepositorySourceControlAiActionRows({
/>
</div>
</div>
{hasOverride ? (
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
<p className="text-[11px] text-muted-foreground">
{actionDirty
? translate(
'auto.components.settings.RepositorySourceControlAiSection.e57dde9d93',
'Unsaved changes'
)
: translate(
'auto.components.settings.RepositorySourceControlAiSection.ccb07dd027',
'Saved'
)}
</p>
<div className="flex items-center gap-2">
{actionDirty ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onActionDiscard(actionId)}
disabled={isSaving}
>
{translate(
'auto.components.settings.RepositorySourceControlAiSection.67b3ff5467',
'Discard'
)}
</Button>
) : null}
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => onActionSave(actionId)}
disabled={!actionDirty || isSaving}
>
{isSaving
? translate(
'auto.components.settings.RepositorySourceControlAiSection.57e6e9d4b1',
'Saving...'
)
: translate(
'auto.components.settings.RepositorySourceControlAiSection.152268c295',
'Save'
)}
</Button>
</div>
</div>
) : null}
</div>
)
})}
@@ -6,11 +6,16 @@ import type {
RepoSourceControlAiOverrides,
SourceControlAiSettings
} from '../../../../shared/source-control-ai-types'
import {
SOURCE_CONTROL_ACTION_IDS,
type SourceControlActionId
} from '../../../../shared/source-control-ai-actions'
import {
createRepoAiDraftState,
dropRepoLegacyInstructionForAction,
resolveRepoAiDraftState
} from './RepositorySourceControlAiSection'
import { RepositorySourceControlAiActionRows } from './RepositorySourceControlAiActionRows'
import { RepositorySourceControlAiEnablement } from './RepositorySourceControlAiEnablement'
describe('RepositorySourceControlAiSection draft state', () => {
@@ -98,6 +103,53 @@ describe('RepositorySourceControlAiEnablement', () => {
})
})
describe('RepositorySourceControlAiActionRows', () => {
it('renders save controls on dirty repository action recipes', () => {
const actionDirtyById = Object.fromEntries(
SOURCE_CONTROL_ACTION_IDS.map((actionId) => [actionId, actionId === 'fixCommitFailure'])
) as Record<SourceControlActionId, boolean>
const source = {
enabled: true,
agentId: null,
selectedModelByAgent: {},
selectedThinkingByModel: {},
customAgentCommand: '',
instructionsByOperation: {}
} as SourceControlAiSettings
const markup = renderToStaticMarkup(
React.createElement(RepositorySourceControlAiActionRows, {
repoId: 'repo-1',
repoAi: {
actionOverrides: {
fixCommitFailure: {
agentId: null,
commandInputTemplate: '{basePrompt}\n\nrepo'
}
}
},
source,
defaultTuiAgent: 'codex',
isSaving: false,
actionDirtyById,
onActionModeChange: () => {},
onActionAgentChange: () => {},
onActionTemplateChange: () => {},
onActionAgentArgsChange: () => {},
onAppendVariable: () => {},
onActionDiscard: () => {},
onActionSave: () => {}
})
)
expect(markup).toContain('Commit failure fixes')
expect(markup).toContain('Unsaved changes')
expect(markup).toContain('Discard')
expect(markup).toContain('Save')
expect(markup).toContain('repo-repo-1-source-control-ai-fixCommitFailure')
})
})
describe('dropRepoLegacyInstructionForAction', () => {
it('prevents legacy text instructions from remigrating after an action override is cleared', () => {
const next = dropRepoLegacyInstructionForAction(
@@ -1,16 +1,18 @@
import { useMemo, useState } from 'react'
import type React from 'react'
import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types'
import type { Repo, TuiAgent } from '../../../../shared/types'
import { CUSTOM_AGENT_ID } from '../../../../shared/commit-message-agent-spec'
import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types'
import {
normalizeRepoSourceControlAiOverrides,
normalizeSourceControlAiSettings,
resolveSourceControlActionRecipe
normalizeSourceControlAiSettings
} from '../../../../shared/source-control-ai'
import { toSourceControlAiRepoUpdate } from '../../../../shared/source-control-ai-recipe-save'
import type { SourceControlAiRepoUpdate } from '../../../../shared/source-control-ai-recipe-save'
import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions'
import {
SOURCE_CONTROL_ACTION_IDS,
type SourceControlActionId
} from '../../../../shared/source-control-ai-actions'
import { Button } from '../ui/button'
import { useAppStore } from '../../store'
import { useMountedRef } from '@/hooks/useMountedRef'
@@ -20,18 +22,21 @@ import { RepositorySourceControlAiCustomCommand } from './RepositorySourceContro
import { RepositorySourceControlAiEnablement } from './RepositorySourceControlAiEnablement'
import { RepositorySourceControlAiHostedReviewDefaults } from './RepositorySourceControlAiHostedReviewDefaults'
import {
buildActionScopedRepoAiSave,
createRepoAiDraftState,
dropRepoLegacyInstructionForAction,
hasOwnActionOverride,
normalizeRepoAiDraft,
readCompleteRecipeForDraft,
resolveRepoAiDraftState,
serializeActionOverride,
serializeRepoAiDraft,
setActionOverride,
type RepoAiDraftState
} from './repository-source-control-ai-draft'
import {
ACTION_MODE_INHERIT,
DEFAULT_AGENT_VALUE,
completeRepoActionRecipe,
readInheritedCommandTemplate
} from './repository-source-control-ai-labels'
import { getSettingOwnershipSummary } from './setting-ownership'
@@ -50,40 +55,6 @@ type RepositorySourceControlAiSectionProps = {
type HostedReviewDefaultKey = keyof NonNullable<RepoSourceControlAiOverrides['prCreationDefaults']>
function readCompleteRecipeForDraft(
current: RepoSourceControlAiOverrides,
settings: GlobalSettings | null,
actionId: SourceControlActionId
): NonNullable<
NonNullable<RepoSourceControlAiOverrides['actionOverrides']>[SourceControlActionId]
> {
const recipe = resolveSourceControlActionRecipe({
settings,
repo: { sourceControlAi: current },
actionId
})
return completeRepoActionRecipe(recipe, actionId)
}
function setActionOverride(
current: RepoSourceControlAiOverrides,
actionId: SourceControlActionId,
recipe: NonNullable<
NonNullable<RepoSourceControlAiOverrides['actionOverrides']>[SourceControlActionId]
>
): RepoSourceControlAiOverrides {
return dropRepoLegacyInstructionForAction(
{
...current,
actionOverrides: {
...current.actionOverrides,
[actionId]: recipe
}
},
actionId
)
}
export function RepositorySourceControlAiSection({
repo,
updateRepo
@@ -130,6 +101,17 @@ export function RepositorySourceControlAiSection({
const draftSerialized = useMemo(() => serializeRepoAiDraft(repoAi), [repoAi])
const isDirty =
resolvedDraftState.repoId !== repo.id || draftSerialized !== resolvedDraftState.baseSerialized
const actionDirtyById = useMemo(
() =>
Object.fromEntries(
SOURCE_CONTROL_ACTION_IDS.map((actionId) => [
actionId,
serializeActionOverride(repoAi, actionId) !==
serializeActionOverride(persistedRepoAi, actionId)
])
) as Record<SourceControlActionId, boolean>,
[persistedRepoAi, repoAi]
)
const updateDraftRepoAi = (
update: (current: RepoSourceControlAiOverrides) => RepoSourceControlAiOverrides
@@ -198,6 +180,50 @@ export function RepositorySourceControlAiSection({
setSaveError(null)
}
const discardActionDraft = (actionId: SourceControlActionId): void => {
updateDraftRepoAi((current) => {
const nextActionOverrides = { ...current.actionOverrides }
if (hasOwnActionOverride(persistedRepoAi.actionOverrides, actionId)) {
nextActionOverrides[actionId] = persistedRepoAi.actionOverrides?.[actionId]
} else {
delete nextActionOverrides[actionId]
}
return dropRepoLegacyInstructionForAction(
{ ...current, actionOverrides: nextActionOverrides },
actionId
)
})
}
const saveActionDraft = async (actionId: SourceControlActionId): Promise<void> => {
if (isSaving || !actionDirtyById[actionId]) {
return
}
// Why: a per-action Save persists only that action's override (its Discard is
// action-scoped, so its Save must be too) — never other rows' pending edits.
const next = buildActionScopedRepoAiSave(persistedRepoAi, repoAi, actionId)
const repoUpdate = toSourceControlAiRepoUpdate(next)
setIsSaving(true)
setSaveError(null)
try {
const result = await updateRepo(repo.id, repoUpdate)
if (!mountedRef.current) {
return
}
if (result === false) {
setSaveError('Failed to save Source Control AI settings.')
}
} catch {
if (mountedRef.current) {
setSaveError('Failed to save Source Control AI settings.')
}
} finally {
if (mountedRef.current) {
setIsSaving(false)
}
}
}
const updateEnablement = (value: boolean | undefined): void => {
updateDraftRepoAi((current) => ({ ...current, enabled: value }))
}
@@ -364,14 +390,19 @@ export function RepositorySourceControlAiSection({
onChange={updateCustomCommand}
/>
<RepositorySourceControlAiActionRows
repoId={repo.id}
repoAi={repoAi}
source={source}
defaultTuiAgent={settings?.defaultTuiAgent}
isSaving={isSaving}
actionDirtyById={actionDirtyById}
onActionModeChange={updateActionMode}
onActionAgentChange={updateActionAgent}
onActionTemplateChange={updateActionTemplate}
onActionAgentArgsChange={updateActionAgentArgs}
onAppendVariable={appendVariable}
onActionDiscard={discardActionDraft}
onActionSave={(actionId) => void saveActionDraft(actionId)}
/>
<RepositorySourceControlAiHostedReviewDefaults
value={repoAi.prCreationDefaults}
@@ -32,6 +32,7 @@ type SourceControlActionRecipeRowProps = {
baseValue: ActionRecipeDraftValue
defaultTuiAgent: GlobalSettings['defaultTuiAgent']
isSavingTemplate: boolean
repoOverrideNote?: React.ReactNode
onAgentChange: (actionId: SourceControlActionId, value: string) => void
onTemplateChange: (actionId: SourceControlActionId, value: string) => void
onAgentArgsChange: (actionId: SourceControlActionId, value: string) => void
@@ -57,6 +58,7 @@ export function SourceControlActionRecipeRow({
baseValue,
defaultTuiAgent,
isSavingTemplate,
repoOverrideNote,
onAgentChange,
onTemplateChange,
onAgentArgsChange,
@@ -163,50 +165,53 @@ export function SourceControlActionRecipeRow({
/>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-muted-foreground">
{templateDirty
? translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.817128d94e',
'Unsaved changes'
)
: translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.9d3cc627f8',
'Saved'
)}
</p>
<div className="flex items-center gap-2">
{templateDirty ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onDiscard(actionId)}
disabled={isSavingTemplate}
>
{translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.b3914ecbbc',
'Discard'
)}
</Button>
) : null}
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => onSave(actionId)}
disabled={!templateDirty || isSavingTemplate}
>
{isSavingTemplate
<div className="mt-3 space-y-2">
{repoOverrideNote}
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-muted-foreground">
{templateDirty
? translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.4f549a5fa8',
'Saving...'
'auto.components.settings.SourceControlAiActionRecipeDefaults.817128d94e',
'Unsaved changes'
)
: translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.d18d665e12',
'Save'
'auto.components.settings.SourceControlAiActionRecipeDefaults.9d3cc627f8',
'Saved'
)}
</Button>
</p>
<div className="flex items-center gap-2">
{templateDirty ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onDiscard(actionId)}
disabled={isSavingTemplate}
>
{translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.b3914ecbbc',
'Discard'
)}
</Button>
) : null}
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => onSave(actionId)}
disabled={!templateDirty || isSavingTemplate}
>
{isSavingTemplate
? translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.4f549a5fa8',
'Saving...'
)
: translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.d18d665e12',
'Save'
)}
</Button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,83 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '../ui/tooltip'
import { SourceControlActionRepoOverrideNote } from './SourceControlActionRepoOverrideNote'
import type { SourceControlActionRecipeOverrideSummary } from '@/lib/source-control-launch-agent-selection'
let container: HTMLDivElement
let root: Root
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
function renderNote(
summary: SourceControlActionRecipeOverrideSummary,
onReviewRepo = vi.fn()
): void {
act(() => {
root.render(
<TooltipProvider>
<SourceControlActionRepoOverrideNote summary={summary} onReviewRepo={onReviewRepo} />
</TooltipProvider>
)
})
}
describe('SourceControlActionRepoOverrideNote', () => {
it('renders nothing when no repo overrides the action', () => {
renderNote({ count: 0, overrides: [] })
expect(container.textContent).toBe('')
})
it('shows the singular count when one repo overrides the action', () => {
renderNote({
count: 1,
overrides: [{ repoId: 'r1', repoName: 'App', fields: ['commandTemplate'] }]
})
expect(container.textContent).toContain("Global saves won't change 1 repository")
expect(container.textContent).toContain('Review')
})
it('shows the plural count when several repos override the action', () => {
renderNote({
count: 2,
overrides: [
{ repoId: 'r1', repoName: 'App', fields: ['agent'] },
{ repoId: 'r2', repoName: 'Web', fields: ['agentArgs'] }
]
})
expect(container.textContent).toContain("Global saves won't change 2 repositories")
expect(container.textContent).toContain('Review first')
})
it('opens the first repo override from the review action', () => {
const onReviewRepo = vi.fn()
renderNote(
{
count: 2,
overrides: [
{ repoId: 'r1', repoName: 'App', fields: ['agent'] },
{ repoId: 'r2', repoName: 'Web', fields: ['agentArgs'] }
]
},
onReviewRepo
)
act(() => {
container.querySelector('button')?.click()
})
expect(onReviewRepo).toHaveBeenCalledWith('r1')
})
})
@@ -0,0 +1,126 @@
import type React from 'react'
import { Info } from 'lucide-react'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import type {
SourceControlActionRecipeOverrideField,
SourceControlActionRecipeOverrideSummary
} from '@/lib/source-control-launch-agent-selection'
import { translate } from '@/i18n/i18n'
type SourceControlActionRepoOverrideNoteProps = {
summary: SourceControlActionRecipeOverrideSummary
onReviewRepo: (repoId: string) => void
}
const MAX_VISIBLE_REPOS = 5
function getRecipeOverrideFieldLabel(field: SourceControlActionRecipeOverrideField): string {
if (field === 'agent') {
return translate('auto.components.settings.SourceControlActionRepoOverrideNote.agent', 'Agent')
}
if (field === 'agentArgs') {
return translate(
'auto.components.settings.SourceControlActionRepoOverrideNote.agentArgs',
'CLI arguments'
)
}
return translate(
'auto.components.settings.SourceControlActionRepoOverrideNote.commandTemplate',
'Command template'
)
}
function getRecipeOverrideFieldSummary(fields: SourceControlActionRecipeOverrideField[]): string {
if (fields.length === 0) {
return translate(
'auto.components.settings.SourceControlActionRepoOverrideNote.recipe',
'Recipe'
)
}
return fields.map(getRecipeOverrideFieldLabel).join(', ')
}
export function SourceControlActionRepoOverrideNote({
summary,
onReviewRepo
}: SourceControlActionRepoOverrideNoteProps): React.JSX.Element | null {
if (summary.count === 0) {
return null
}
const firstOverride = summary.overrides[0]
if (!firstOverride) {
return null
}
const visibleOverrides = summary.overrides.slice(0, MAX_VISIBLE_REPOS)
const hiddenOverrideCount = Math.max(0, summary.overrides.length - visibleOverrides.length)
const noteText =
summary.count === 1
? translate(
'auto.components.settings.SourceControlActionRepoOverrideNote.singular',
"Global saves won't change 1 repository with its own recipe."
)
: translate(
'auto.components.settings.SourceControlActionRepoOverrideNote.plural',
"Global saves won't change {{count}} repositories with their own recipes.",
{ count: summary.count }
)
return (
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 rounded-md bg-muted/40 px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex min-w-0 items-start gap-1.5">
<Info className="mt-px size-3 shrink-0" />
<span>{noteText}</span>
</span>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-[18rem]">
<div className="space-y-1.5">
<p className="text-[11px] font-medium">
{translate(
'auto.components.settings.SourceControlActionRepoOverrideNote.tooltipTitle',
'Repository overrides'
)}
</p>
<ul className="space-y-1">
{visibleOverrides.map((override) => (
<li key={override.repoId} className="space-y-0.5">
<div>{override.repoName}</div>
<div className="text-[11px] text-muted-foreground">
{getRecipeOverrideFieldSummary(override.fields)}
</div>
</li>
))}
</ul>
{hiddenOverrideCount > 0 ? (
<p className="text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.SourceControlActionRepoOverrideNote.more',
'+{{count}} more',
{ count: hiddenOverrideCount }
)}
</p>
) : null}
</div>
</TooltipContent>
</Tooltip>
<Button
type="button"
variant="link"
size="xs"
className="h-auto px-0 py-0 text-[11px]"
onClick={() => onReviewRepo(firstOverride.repoId)}
>
{summary.count === 1
? translate(
'auto.components.settings.SourceControlActionRepoOverrideNote.review',
'Review'
)
: translate(
'auto.components.settings.SourceControlActionRepoOverrideNote.reviewFirst',
'Review first'
)}
</Button>
</div>
)
}
@@ -1,6 +1,7 @@
import type React from 'react'
import { toast } from 'sonner'
import type { GlobalSettings, TuiAgent } from '../../../../shared/types'
import { isFolderRepo } from '../../../../shared/repo-kind'
import type {
SourceControlAiSettings,
SourceControlAiSettingsPatch
@@ -13,9 +14,14 @@ import {
type SourceControlActionId
} from '../../../../shared/source-control-ai-actions'
import { Label } from '../ui/label'
import { useAppStore } from '@/store'
import { useRepos } from '@/store/selectors'
import { summarizeReposOverridingActionRecipe } from '@/lib/source-control-launch-agent-selection'
import { SearchableSetting } from './SearchableSetting'
import { getRepositorySourceControlAiActionRecipeSectionId } from './repository-settings-targets'
import { matchesSettingsSearch } from './settings-search'
import { SourceControlActionRecipeRow } from './SourceControlActionRecipeRow'
import { SourceControlActionRepoOverrideNote } from './SourceControlActionRepoOverrideNote'
import { useSourceControlActionRecipeDraftState } from './source-control-action-recipe-draft-state'
import { translate } from '@/i18n/i18n'
@@ -84,6 +90,9 @@ export function SourceControlAiActionRecipeDefaults({
searchQuery,
writeConfig
}: SourceControlAiActionRecipeDefaultsProps): React.JSX.Element | null {
const repos = useRepos().filter((repo) => !isFolderRepo(repo))
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
const {
actionRecipeDraftState,
savingActionTemplateIds,
@@ -134,6 +143,18 @@ export function SourceControlAiActionRecipeDefaults({
}
}
const openRepoSourceControlAiSettings = (
repoId: string,
actionId: SourceControlActionId
): void => {
openSettingsTarget({
pane: 'repo',
repoId,
sectionId: getRepositorySourceControlAiActionRecipeSectionId(repoId, actionId)
})
openSettingsPage()
}
if (!config.enabled || !matchesSettingsSearch(searchQuery, ACTION_RECIPES_SEARCH_ENTRY)) {
return null
}
@@ -158,6 +179,10 @@ export function SourceControlAiActionRecipeDefaults({
{SOURCE_CONTROL_ACTION_IDS.map((actionId) => {
const recipe = config.actions?.[actionId]
const selectedAgent = recipe?.agentId ?? null
const overrideSummary = summarizeReposOverridingActionRecipe({
repos,
actionId
})
return (
<SourceControlActionRecipeRow
key={actionId}
@@ -167,6 +192,12 @@ export function SourceControlAiActionRecipeDefaults({
baseValue={actionRecipeDraftState.baseValues[actionId]}
defaultTuiAgent={defaultTuiAgent}
isSavingTemplate={savingActionTemplateIds[actionId] === true}
repoOverrideNote={
<SourceControlActionRepoOverrideNote
summary={overrideSummary}
onReviewRepo={(repoId) => openRepoSourceControlAiSettings(repoId, actionId)}
/>
}
onAgentChange={(id, value) => void onActionAgentChange(id, value)}
onTemplateChange={onActionTemplateChange}
onAgentArgsChange={onActionAgentArgsChange}
@@ -1,3 +1,5 @@
import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions'
export function getRepositoryLocalCommandsSectionId(repoId: string): string {
return `repo-${repoId}-local-commands`
}
@@ -9,3 +11,10 @@ export function getRepositoryIconSectionId(repoId: string): string {
export function getRepositorySourceControlAiSectionId(repoId: string): string {
return `repo-${repoId}-source-control-ai`
}
export function getRepositorySourceControlAiActionRecipeSectionId(
repoId: string,
actionId: SourceControlActionId
): string {
return `repo-${repoId}-source-control-ai-${actionId}`
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types'
import { buildActionScopedRepoAiSave } from './repository-source-control-ai-draft'
describe('buildActionScopedRepoAiSave', () => {
it('persists only the target action, keeping siblings at their saved values', () => {
const persisted: RepoSourceControlAiOverrides = {
actionOverrides: {
resolveConflicts: {
agentId: 'claude',
commandInputTemplate: '{basePrompt}',
agentArgs: ''
},
fixCommitFailure: { agentId: 'codex', commandInputTemplate: '{basePrompt}', agentArgs: '' }
}
}
const draft: RepoSourceControlAiOverrides = {
actionOverrides: {
resolveConflicts: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}\n\nedited',
agentArgs: ''
},
// A half-finished edit on a different row that must NOT be flushed.
fixCommitFailure: {
agentId: 'gemini',
commandInputTemplate: '{basePrompt}\n\nunsaved',
agentArgs: ''
}
}
}
const next = buildActionScopedRepoAiSave(persisted, draft, 'resolveConflicts')
expect(next.actionOverrides?.resolveConflicts?.agentId).toBe('codex')
expect(next.actionOverrides?.resolveConflicts?.commandInputTemplate).toBe(
'{basePrompt}\n\nedited'
)
// Sibling keeps the persisted value, not the draft's pending edit.
expect(next.actionOverrides?.fixCommitFailure?.agentId).toBe('codex')
expect(next.actionOverrides?.fixCommitFailure?.commandInputTemplate).toBe('{basePrompt}')
})
it('removes the action override when the draft inherits it', () => {
const persisted: RepoSourceControlAiOverrides = {
actionOverrides: {
resolveConflicts: { agentId: 'claude', commandInputTemplate: '{basePrompt}', agentArgs: '' }
}
}
const draft: RepoSourceControlAiOverrides = { actionOverrides: {} }
const next = buildActionScopedRepoAiSave(persisted, draft, 'resolveConflicts')
expect(next.actionOverrides?.resolveConflicts).toBeUndefined()
})
})
@@ -1,8 +1,17 @@
import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
import {
normalizeRepoSourceControlAiOverrides,
resolveSourceControlActionRecipe
} from '../../../../shared/source-control-ai'
import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions'
import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types'
import type { GlobalSettings } from '../../../../shared/types'
import { completeRepoActionRecipe } from './repository-source-control-ai-labels'
import { SOURCE_CONTROL_TEXT_ACTION_ID_SET } from './source-control-action-recipe-options'
type RepoActionRecipe = NonNullable<
NonNullable<RepoSourceControlAiOverrides['actionOverrides']>[SourceControlActionId]
>
export type RepoAiDraftState = {
repoId: string
value: RepoSourceControlAiOverrides
@@ -93,3 +102,66 @@ export function dropRepoLegacyInstructionForAction(
Object.keys(instructionsByOperation).length > 0 ? instructionsByOperation : undefined
}
}
export function readCompleteRecipeForDraft(
current: RepoSourceControlAiOverrides,
settings: GlobalSettings | null,
actionId: SourceControlActionId
): RepoActionRecipe {
const recipe = resolveSourceControlActionRecipe({
settings,
repo: { sourceControlAi: current },
actionId
})
return completeRepoActionRecipe(recipe, actionId)
}
export function setActionOverride(
current: RepoSourceControlAiOverrides,
actionId: SourceControlActionId,
recipe: RepoActionRecipe
): RepoSourceControlAiOverrides {
return dropRepoLegacyInstructionForAction(
{
...current,
actionOverrides: {
...current.actionOverrides,
[actionId]: recipe
}
},
actionId
)
}
export function serializeActionOverride(
value: RepoSourceControlAiOverrides,
actionId: SourceControlActionId
): string {
return JSON.stringify({
hasOverride: hasOwnActionOverride(value.actionOverrides, actionId),
recipe: value.actionOverrides?.[actionId] ?? null
})
}
/**
* Layer only one action's draft override onto the last-saved repo settings, so a
* per-action save persists that recipe without flushing other rows' edits.
*/
export function buildActionScopedRepoAiSave(
persisted: RepoSourceControlAiOverrides,
draft: RepoSourceControlAiOverrides,
actionId: SourceControlActionId
): RepoSourceControlAiOverrides {
const nextActionOverrides = { ...persisted.actionOverrides }
if (hasOwnActionOverride(draft.actionOverrides, actionId)) {
nextActionOverrides[actionId] = draft.actionOverrides?.[actionId]
} else {
delete nextActionOverrides[actionId]
}
return normalizeRepoAiDraft(
dropRepoLegacyInstructionForAction(
{ ...persisted, actionOverrides: nextActionOverrides },
actionId
)
)
}
+14 -1
View File
@@ -8709,6 +8709,18 @@
"ephemeralVmsExperimentalSetting": {
"description": "Shows setup controls and workspace run targets for repo-owned, on-demand environments.",
"toggleLabel": "Toggle per-workspace environments"
},
"SourceControlActionRepoOverrideNote": {
"agent": "Agent",
"agentArgs": "CLI arguments",
"commandTemplate": "Command template",
"more": "+{{count}} more",
"plural": "Global saves won't change {{count}} repositories with their own recipes.",
"recipe": "Recipe",
"review": "Review",
"reviewFirst": "Review first",
"singular": "Global saves won't change 1 repository with its own recipe.",
"tooltipTitle": "Repository overrides"
}
},
"right": {
@@ -9206,7 +9218,8 @@
"c7ff8cef11": "Detecting agents...",
"b0da3a4d3e": "Launch recipe already saved",
"bff4795a6d": "Change the agent, arguments, or prompt template to update the saved recipe.",
"5c75b24735": "Customize what the agent receives before Orca starts it."
"5c75b24735": "Customize what the agent receives before Orca starts it.",
"repoAgentOverrideNote": "This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here."
},
"SourceControlTextGenerationDialog": {
"c5b7fa7cb6": "Save as global default",
+14 -1
View File
@@ -8709,6 +8709,18 @@
"ephemeralVmsExperimentalSetting": {
"description": "Muestra controles de configuración y objetivos de ejecución del espacio de trabajo para entornos bajo demanda de propiedad de repos.",
"toggleLabel": "Alternar entornos por espacio de trabajo"
},
"SourceControlActionRepoOverrideNote": {
"agent": "Agent",
"agentArgs": "CLI arguments",
"commandTemplate": "Command template",
"more": "+{{count}} more",
"plural": "Global saves won't change {{count}} repositories with their own recipes.",
"recipe": "Recipe",
"review": "Review",
"reviewFirst": "Review first",
"singular": "Global saves won't change 1 repository with its own recipe.",
"tooltipTitle": "Repository overrides"
}
},
"right": {
@@ -9206,7 +9218,8 @@
"c7ff8cef11": "Detectando agents...",
"b0da3a4d3e": "La receta de lanzamiento ya está guardada",
"bff4795a6d": "Cambia el agente, los argumentos o la plantilla del prompt para actualizar la receta guardada.",
"5c75b24735": "Personaliza lo que recibe el agente antes de que Orca lo inicie."
"5c75b24735": "Personaliza lo que recibe el agente antes de que Orca lo inicie.",
"repoAgentOverrideNote": "This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here."
},
"SourceControlTextGenerationDialog": {
"c5b7fa7cb6": "Guardar como valor predeterminado global",
+14 -1
View File
@@ -8709,6 +8709,18 @@
"ephemeralVmsExperimentalSetting": {
"description": "repo が所有するオンデマンド環境のセットアップ コントロールとワークスペース実行ターゲットを示します。",
"toggleLabel": "ワークスペースごとの環境を切り替える"
},
"SourceControlActionRepoOverrideNote": {
"agent": "Agent",
"agentArgs": "CLI arguments",
"commandTemplate": "Command template",
"more": "+{{count}} more",
"plural": "Global saves won't change {{count}} repositories with their own recipes.",
"recipe": "Recipe",
"review": "Review",
"reviewFirst": "Review first",
"singular": "Global saves won't change 1 repository with its own recipe.",
"tooltipTitle": "Repository overrides"
}
},
"right": {
@@ -9206,7 +9218,8 @@
"ea4788705e": "キャンセル",
"b0da3a4d3e": "起動レシピはすでに保存されています",
"bff4795a6d": "保存済みのレシピを更新するには、agent、引数、またはプロンプトテンプレートを変更してください。",
"5c75b24735": "Orca が起動する前に agent が受け取る内容をカスタマイズします。"
"5c75b24735": "Orca が起動する前に agent が受け取る内容をカスタマイズします。",
"repoAgentOverrideNote": "This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here."
},
"SourceControlTextGenerationDialog": {
"c5b7fa7cb6": "グローバルデフォルトとして保存",
+14 -1
View File
@@ -8709,6 +8709,18 @@
"ephemeralVmsExperimentalSetting": {
"description": "repo 소유 온디맨드 환경에 대한 설정 제어 및 워크스페이스 실행 대상을 표시합니다.",
"toggleLabel": "워크스페이스별 환경 전환"
},
"SourceControlActionRepoOverrideNote": {
"agent": "Agent",
"agentArgs": "CLI arguments",
"commandTemplate": "Command template",
"more": "+{{count}} more",
"plural": "Global saves won't change {{count}} repositories with their own recipes.",
"recipe": "Recipe",
"review": "Review",
"reviewFirst": "Review first",
"singular": "Global saves won't change 1 repository with its own recipe.",
"tooltipTitle": "Repository overrides"
}
},
"right": {
@@ -9206,7 +9218,8 @@
"ea4788705e": "취소",
"b0da3a4d3e": "실행 레시피가 이미 저장되었습니다",
"bff4795a6d": "저장된 레시피를 업데이트하려면 agent, 인수 또는 프롬프트 템플릿을 변경하세요.",
"5c75b24735": "Orca가 시작하기 전에 agent가 받을 내용을 사용자 지정합니다."
"5c75b24735": "Orca가 시작하기 전에 agent가 받을 내용을 사용자 지정합니다.",
"repoAgentOverrideNote": "This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here."
},
"SourceControlTextGenerationDialog": {
"c5b7fa7cb6": "전역 기본값으로 저장",
+14 -1
View File
@@ -8709,6 +8709,18 @@
"ephemeralVmsExperimentalSetting": {
"description": "显示仓库自带按需环境的设置控件和工作区运行目标。",
"toggleLabel": "切换工作区专属环境"
},
"SourceControlActionRepoOverrideNote": {
"agent": "Agent",
"agentArgs": "CLI arguments",
"commandTemplate": "Command template",
"more": "+{{count}} more",
"plural": "Global saves won't change {{count}} repositories with their own recipes.",
"recipe": "Recipe",
"review": "Review",
"reviewFirst": "Review first",
"singular": "Global saves won't change 1 repository with its own recipe.",
"tooltipTitle": "Repository overrides"
}
},
"right": {
@@ -9206,7 +9218,8 @@
"ea4788705e": "取消",
"b0da3a4d3e": "启动配方已保存",
"bff4795a6d": "更改智能体、参数或提示词模板以更新已保存的配方。",
"5c75b24735": "自定义 Orca 启动智能体前发送给它的内容。"
"5c75b24735": "自定义 Orca 启动智能体前发送给它的内容。",
"repoAgentOverrideNote": "This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here."
},
"SourceControlTextGenerationDialog": {
"c5b7fa7cb6": "保存为全局默认值",
@@ -0,0 +1,148 @@
import { describe, expect, it } from 'vitest'
import { getDefaultSettings } from '../../../shared/constants'
import type { GlobalSettings, Repo, TuiAgent } from '../../../shared/types'
import {
resolveSourceControlLaunchAgentScope,
summarizeReposOverridingActionRecipe
} from './source-control-launch-agent-selection'
function settingsWithGlobalResolveAgent(agentId: TuiAgent): GlobalSettings {
const base = getDefaultSettings('/tmp')
return {
...base,
defaultTuiAgent: agentId,
sourceControlAi: {
...base.sourceControlAi!,
enabled: true,
actions: {
...base.sourceControlAi!.actions,
resolveConflicts: { agentId, commandInputTemplate: '{basePrompt}', agentArgs: '' }
}
}
}
}
function repoWithResolveAgent(agentId: TuiAgent): Pick<Repo, 'sourceControlAi'> {
return {
sourceControlAi: {
actionOverrides: {
resolveConflicts: { agentId, commandInputTemplate: '{basePrompt}', agentArgs: '' }
}
}
}
}
describe('resolveSourceControlLaunchAgentScope', () => {
it('flags a repo override that pins a different agent than the global default', () => {
const scope = resolveSourceControlLaunchAgentScope({
settings: settingsWithGlobalResolveAgent('claude'),
repo: repoWithResolveAgent('codex'),
actionId: 'resolveConflicts'
})
expect(scope).toEqual({
effectiveAgentId: 'codex',
globalAgentId: 'claude',
overridesGlobalAgent: true
})
})
it('does not flag a repo override that matches the global agent', () => {
const scope = resolveSourceControlLaunchAgentScope({
settings: settingsWithGlobalResolveAgent('claude'),
repo: repoWithResolveAgent('claude'),
actionId: 'resolveConflicts'
})
expect(scope.overridesGlobalAgent).toBe(false)
})
it('does not flag when there is no repo override', () => {
const scope = resolveSourceControlLaunchAgentScope({
settings: settingsWithGlobalResolveAgent('claude'),
repo: null,
actionId: 'resolveConflicts'
})
expect(scope).toEqual({
effectiveAgentId: 'claude',
globalAgentId: 'claude',
overridesGlobalAgent: false
})
})
it('falls back to the default agent when no global recipe agent is set', () => {
const base = getDefaultSettings('/tmp')
const settings: GlobalSettings = {
...base,
defaultTuiAgent: 'claude',
sourceControlAi: { ...base.sourceControlAi!, enabled: true }
}
const scope = resolveSourceControlLaunchAgentScope({
settings,
repo: repoWithResolveAgent('codex'),
actionId: 'resolveConflicts'
})
expect(scope.globalAgentId).toBe('claude')
expect(scope.overridesGlobalAgent).toBe(true)
})
})
function repoOverriding(
id: string,
displayName: string,
agentId: TuiAgent
): Pick<Repo, 'id' | 'displayName' | 'sourceControlAi'> {
return { id, displayName, ...repoWithResolveAgent(agentId) }
}
describe('summarizeReposOverridingActionRecipe', () => {
it('lists repos with any action recipe override', () => {
const summary = summarizeReposOverridingActionRecipe({
repos: [
repoOverriding('repo-1', 'App', 'claude'),
{
id: 'repo-2',
displayName: 'Web',
sourceControlAi: {
actionOverrides: {
resolveConflicts: {
commandInputTemplate: '{basePrompt}\n\nUse project conflict rules.',
agentArgs: '--model gpt-5.4-mini'
}
}
}
},
{ id: 'repo-3', displayName: 'Docs' }
],
actionId: 'resolveConflicts'
})
expect(summary).toEqual({
count: 2,
overrides: [
{ repoId: 'repo-1', repoName: 'App', fields: ['agent', 'commandTemplate', 'agentArgs'] },
{ repoId: 'repo-2', repoName: 'Web', fields: ['commandTemplate', 'agentArgs'] }
]
})
})
it('includes legacy repo instructions as command template overrides', () => {
const summary = summarizeReposOverridingActionRecipe({
repos: [
{
id: 'repo-1',
displayName: 'App',
sourceControlAi: {
instructionsByOperation: {
commitMessage: 'Use project style.'
}
}
}
],
actionId: 'commitMessage'
})
expect(summary).toEqual({
count: 1,
overrides: [{ repoId: 'repo-1', repoName: 'App', fields: ['commandTemplate'] }]
})
})
})
@@ -1,8 +1,15 @@
import { getAgentCatalog } from '@/lib/agent-catalog'
import { isCustomAgentId } from '../../../shared/commit-message-agent-spec'
import type { SourceControlActionRecipe } from '../../../shared/source-control-ai-actions'
import {
normalizeRepoSourceControlAiOverrides,
resolveSourceControlActionRecipe
} from '../../../shared/source-control-ai'
import type {
SourceControlActionId,
SourceControlActionRecipe
} from '../../../shared/source-control-ai-actions'
import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection'
import type { TuiAgent } from '../../../shared/types'
import type { GlobalSettings, Repo, TuiAgent } from '../../../shared/types'
export function readSourceControlLaunchRecipeAgentId(
recipe: Pick<SourceControlActionRecipe, 'agentId'> | null | undefined
@@ -30,3 +37,122 @@ export function pickSourceControlLaunchAgent(args: {
}
return getAgentCatalog().find((entry) => enabledAgents.includes(entry.id))?.id ?? null
}
export type SourceControlLaunchAgentScope = {
/** Agent the button would launch, after applying any repo override. */
effectiveAgentId: TuiAgent | null
/** Agent the button would launch without a repo override (global recipe or default). */
globalAgentId: TuiAgent | null
/**
* True when this repo pins a different launch agent than the global default —
* the silent-override case we surface instead of auto-launching blindly.
*/
overridesGlobalAgent: boolean
}
export function resolveSourceControlLaunchAgentScope(input: {
settings:
| Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi' | 'defaultTuiAgent'>
| null
| undefined
repo?: Pick<Repo, 'sourceControlAi'> | null
actionId: SourceControlActionId
}): SourceControlLaunchAgentScope {
const effectiveAgentId = readSourceControlLaunchRecipeAgentId(
resolveSourceControlActionRecipe({
settings: input.settings,
repo: input.repo,
actionId: input.actionId
})
)
const globalRecipeAgentId = readSourceControlLaunchRecipeAgentId(
resolveSourceControlActionRecipe({
settings: input.settings,
repo: null,
actionId: input.actionId
})
)
// Why: the note compares against what would run with no override, so fall back
// to the global default agent when no global recipe agent is set.
const defaultTuiAgent = input.settings?.defaultTuiAgent
const globalAgentId =
globalRecipeAgentId ?? (defaultTuiAgent && defaultTuiAgent !== 'blank' ? defaultTuiAgent : null)
const hasRepoAgentOverride =
normalizeRepoSourceControlAiOverrides(input.repo?.sourceControlAi)?.actionOverrides?.[
input.actionId
]?.agentId !== undefined
return {
effectiveAgentId,
globalAgentId,
overridesGlobalAgent:
hasRepoAgentOverride && effectiveAgentId !== null && effectiveAgentId !== globalAgentId
}
}
export type SourceControlActionRecipeOverrideField = 'agent' | 'commandTemplate' | 'agentArgs'
export type SourceControlActionRecipeOverride = {
repoId: string
repoName: string
fields: SourceControlActionRecipeOverrideField[]
}
export type SourceControlActionRecipeOverrideSummary = {
count: number
overrides: SourceControlActionRecipeOverride[]
}
type NormalizedRepoSourceControlAiOverrides = NonNullable<
ReturnType<typeof normalizeRepoSourceControlAiOverrides>
>
function hasActionOverride(
overrides: NormalizedRepoSourceControlAiOverrides['actionOverrides'] | undefined,
actionId: SourceControlActionId
): boolean {
return Object.prototype.hasOwnProperty.call(overrides ?? {}, actionId)
}
function readRecipeOverrideFields(
recipe:
| NonNullable<NormalizedRepoSourceControlAiOverrides['actionOverrides']>[SourceControlActionId]
| null
| undefined
): SourceControlActionRecipeOverrideField[] {
const fields: SourceControlActionRecipeOverrideField[] = []
if (Object.prototype.hasOwnProperty.call(recipe ?? {}, 'agentId')) {
fields.push('agent')
}
if (Object.prototype.hasOwnProperty.call(recipe ?? {}, 'commandInputTemplate')) {
fields.push('commandTemplate')
}
if (Object.prototype.hasOwnProperty.call(recipe ?? {}, 'agentArgs')) {
fields.push('agentArgs')
}
return fields
}
/**
* Which repos own any recipe setting for an action — lets the global recipe
* editor warn that saving globally will not update those repo-specific recipes.
*/
export function summarizeReposOverridingActionRecipe(input: {
repos: readonly Pick<Repo, 'id' | 'displayName' | 'sourceControlAi'>[]
actionId: SourceControlActionId
}): SourceControlActionRecipeOverrideSummary {
const overrides: SourceControlActionRecipeOverride[] = []
for (const repo of input.repos) {
const actionOverrides = normalizeRepoSourceControlAiOverrides(
repo.sourceControlAi
)?.actionOverrides
if (!hasActionOverride(actionOverrides, input.actionId)) {
continue
}
overrides.push({
repoId: repo.id,
repoName: repo.displayName,
fields: readRecipeOverrideFields(actionOverrides?.[input.actionId])
})
}
return { count: overrides.length, overrides }
}