From 71b9211f0b9b588cddca3b6dbcc2a428e1990a16 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:46:27 -0700 Subject: [PATCH] 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. --- .../SourceControlAgentActionDialog.tsx | 2 + ...ourceControlAgentActionDialogForm.test.tsx | 25 +++ .../SourceControlAgentActionDialogForm.tsx | 29 +++- ...urce-control-agent-action-dialog-result.ts | 6 + .../useSourceControlAgentActionDialog.ts | 37 ++++- .../RepositorySourceControlAiActionRows.tsx | 73 ++++++++- .../RepositorySourceControlAiSection.test.ts | 52 ++++++ .../RepositorySourceControlAiSection.tsx | 109 ++++++++----- .../settings/SourceControlActionRecipeRow.tsx | 85 +++++----- ...urceControlActionRepoOverrideNote.test.tsx | 83 ++++++++++ .../SourceControlActionRepoOverrideNote.tsx | 126 +++++++++++++++ .../SourceControlAiActionRecipeDefaults.tsx | 31 ++++ .../settings/repository-settings-targets.ts | 9 ++ ...repository-source-control-ai-draft.test.ts | 56 +++++++ .../repository-source-control-ai-draft.ts | 74 ++++++++- src/renderer/src/i18n/locales/en.json | 15 +- src/renderer/src/i18n/locales/es.json | 15 +- src/renderer/src/i18n/locales/ja.json | 15 +- src/renderer/src/i18n/locales/ko.json | 15 +- src/renderer/src/i18n/locales/zh.json | 15 +- ...rce-control-launch-agent-selection.test.ts | 148 ++++++++++++++++++ .../source-control-launch-agent-selection.ts | 130 ++++++++++++++- 22 files changed, 1055 insertions(+), 95 deletions(-) create mode 100644 src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx create mode 100644 src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx create mode 100644 src/renderer/src/components/settings/repository-source-control-ai-draft.test.ts create mode 100644 src/renderer/src/lib/source-control-launch-agent-selection.test.ts diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx index 49d01dd3459..a9eb07aa247 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx @@ -65,6 +65,7 @@ export function SourceControlAgentActionDialog( const { handleOpenChange, shouldRenderDialog, + agentScopeNote, agentOptions, selectedAgent, hasEnabledAgents, @@ -101,6 +102,7 @@ export function SourceControlAgentActionDialog( { expect(repoMarkup).not.toContain('Launch recipe already saved') expect(repoMarkup).toContain('Save & 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') + }) }) diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx index 779a200087c..4f2caf0133b 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx @@ -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} + {showSaveLaunchRecipe && agentScopeNote ? ( +
+ + + {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 + } + )} + +
+ ) : null} + {showSaveLaunchRecipe ? (
void shouldRenderDialog: boolean + agentScopeNote: SourceControlAgentScopeNote | null agentOptions: ReturnType selectedAgent: TuiAgent | null hasEnabledAgents: boolean diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts index 7dca868c749..74e7403cd9d 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts @@ -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(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, diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx b/src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx index a44759ff38f..58a735e04dc 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx +++ b/src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx @@ -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 + 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 (
@@ -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 ( -
+

@@ -227,6 +248,54 @@ export function RepositorySourceControlAiActionRows({ />

+ {hasOverride ? ( +
+

+ {actionDirty + ? translate( + 'auto.components.settings.RepositorySourceControlAiSection.e57dde9d93', + 'Unsaved changes' + ) + : translate( + 'auto.components.settings.RepositorySourceControlAiSection.ccb07dd027', + 'Saved' + )} +

+
+ {actionDirty ? ( + + ) : null} + +
+
+ ) : null}
) })} diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts b/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts index 9715002a997..1153604715e 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts +++ b/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts @@ -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 + 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( diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx b/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx index 6f5f771379c..5568912edd2 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx +++ b/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx @@ -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 -function readCompleteRecipeForDraft( - current: RepoSourceControlAiOverrides, - settings: GlobalSettings | null, - actionId: SourceControlActionId -): NonNullable< - NonNullable[SourceControlActionId] -> { - const recipe = resolveSourceControlActionRecipe({ - settings, - repo: { sourceControlAi: current }, - actionId - }) - return completeRepoActionRecipe(recipe, actionId) -} - -function setActionOverride( - current: RepoSourceControlAiOverrides, - actionId: SourceControlActionId, - recipe: NonNullable< - NonNullable[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, + [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 => { + 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} /> void saveActionDraft(actionId)} /> 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({ />
-
-

- {templateDirty - ? translate( - 'auto.components.settings.SourceControlAiActionRecipeDefaults.817128d94e', - 'Unsaved changes' - ) - : translate( - 'auto.components.settings.SourceControlAiActionRecipeDefaults.9d3cc627f8', - 'Saved' - )} -

-
- {templateDirty ? ( - - ) : null} - +

+
+ {templateDirty ? ( + + ) : null} + +
diff --git a/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx new file mode 100644 index 00000000000..a283c70cc07 --- /dev/null +++ b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx @@ -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( + + + + ) + }) +} + +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') + }) +}) diff --git a/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx new file mode 100644 index 00000000000..57d68cbbcf9 --- /dev/null +++ b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx @@ -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 ( +
+ + + + + {noteText} + + + +
+

+ {translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.tooltipTitle', + 'Repository overrides' + )} +

+
    + {visibleOverrides.map((override) => ( +
  • +
    {override.repoName}
    +
    + {getRecipeOverrideFieldSummary(override.fields)} +
    +
  • + ))} +
+ {hiddenOverrideCount > 0 ? ( +

+ {translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.more', + '+{{count}} more', + { count: hiddenOverrideCount } + )} +

+ ) : null} +
+
+
+ +
+ ) +} diff --git a/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx b/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx index c54390e2a8d..68f98ad4947 100644 --- a/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx +++ b/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx @@ -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 ( openRepoSourceControlAiSettings(repoId, actionId)} + /> + } onAgentChange={(id, value) => void onActionAgentChange(id, value)} onTemplateChange={onActionTemplateChange} onAgentArgsChange={onActionAgentArgsChange} diff --git a/src/renderer/src/components/settings/repository-settings-targets.ts b/src/renderer/src/components/settings/repository-settings-targets.ts index 2ab4fd8d5d5..32bcd1f4510 100644 --- a/src/renderer/src/components/settings/repository-settings-targets.ts +++ b/src/renderer/src/components/settings/repository-settings-targets.ts @@ -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}` +} diff --git a/src/renderer/src/components/settings/repository-source-control-ai-draft.test.ts b/src/renderer/src/components/settings/repository-source-control-ai-draft.test.ts new file mode 100644 index 00000000000..1972afde88b --- /dev/null +++ b/src/renderer/src/components/settings/repository-source-control-ai-draft.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/components/settings/repository-source-control-ai-draft.ts b/src/renderer/src/components/settings/repository-source-control-ai-draft.ts index 8eb2875a477..a085821f23f 100644 --- a/src/renderer/src/components/settings/repository-source-control-ai-draft.ts +++ b/src/renderer/src/components/settings/repository-source-control-ai-draft.ts @@ -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[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 + ) + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 20f44425297..23679ff95aa 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index dce7125c0c3..2ac26c94491 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 28a1044c682..ae6f6050143 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": "グローバルデフォルトとして保存", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 88c4baada28..1907d55b8f0 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": "전역 기본값으로 저장", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5970640488e..7b86dda3edd 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": "保存为全局默认值", diff --git a/src/renderer/src/lib/source-control-launch-agent-selection.test.ts b/src/renderer/src/lib/source-control-launch-agent-selection.test.ts new file mode 100644 index 00000000000..63a053f0186 --- /dev/null +++ b/src/renderer/src/lib/source-control-launch-agent-selection.test.ts @@ -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 { + 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 { + 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'] }] + }) + }) +}) diff --git a/src/renderer/src/lib/source-control-launch-agent-selection.ts b/src/renderer/src/lib/source-control-launch-agent-selection.ts index 38660a74ce5..e8c00812590 100644 --- a/src/renderer/src/lib/source-control-launch-agent-selection.ts +++ b/src/renderer/src/lib/source-control-launch-agent-selection.ts @@ -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 | 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 + | null + | undefined + repo?: Pick | 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 +> + +function hasActionOverride( + overrides: NormalizedRepoSourceControlAiOverrides['actionOverrides'] | undefined, + actionId: SourceControlActionId +): boolean { + return Object.prototype.hasOwnProperty.call(overrides ?? {}, actionId) +} + +function readRecipeOverrideFields( + recipe: + | NonNullable[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[] + 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 } +}