Add per-repo Source Control AI enablement, custom command, and save-targ

- Repo overrides now support `enabled` and `customAgentCommand`, letting
  repositories opt in/out of Source Control AI independently and supply a
  repo-scoped custom command that takes precedence over the global one.
- Recipe-save dialogs gained a save-target selector ("Don't save / Save for
  this repo / Save as global default") replacing the old boolean checkbox,
  routing saves through the new `saveSourceControlActionRecipe` helper in
  `source-control-ai-recipe-save.ts`.
- `normalizeRepoSourceControlAiOverrides` now returns `undefined` for empty
  objects and passes the `null` sentinel through the IPC/RPC layer so the
  persistence layer can clear repo overrides cleanly.
- `resolveSourceControlLaunchPlatform` resolves the correct shell platform
  for SSH and WSL worktrees so agent launch commands are built correctly.
- Settings UI gained `RepositorySourceControlAiEnablement` and
  `RepositorySourceControlAiCustomCommand` rows; draft/label logic was
  extracted into focused modules to stay within lint line limits.
This commit is contained in:
Jinjing
2026-06-08 10:40:36 -07:00
parent cb30740674
commit bf3e87e98e
32 changed files with 1660 additions and 580 deletions
+4 -3
View File
@@ -1071,9 +1071,8 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'projectGroupId'
| 'projectGroupOrder'
| 'sourceControlAi'
>
>
> & { sourceControlAi?: Repo['sourceControlAi'] | null }
}
) => {
// Why: validate the persisted preference string at the IPC boundary
@@ -1144,7 +1143,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
) {
delete updates.externalWorktreeVisibilityPromptDismissedAt
}
if ('sourceControlAi' in updates && updates.sourceControlAi !== undefined) {
if ('sourceControlAi' in updates && updates.sourceControlAi === null) {
updates.sourceControlAi = undefined
} else if ('sourceControlAi' in updates && updates.sourceControlAi !== undefined) {
const normalizedSourceControlAi = normalizeRepoSourceControlAiOverrides(
updates.sourceControlAi
)
+24 -1
View File
@@ -2303,6 +2303,24 @@ describe('Store', () => {
expect(reloaded.getRepo('r1')!.sourceControlAi).toBeUndefined()
})
it('updateRepo treats source-control AI null as a transport clear sentinel', async () => {
const store = await createStore()
store.addRepo(
makeRepo({
sourceControlAi: {
enabled: true,
customAgentCommand: 'repo-agent {prompt}'
}
})
)
store.updateRepo('r1', {
sourceControlAi: null
})
expect(store.getRepo('r1')!.sourceControlAi).toBeUndefined()
})
it('updateRepo normalizes source-control AI overrides before storing', async () => {
const store = await createStore()
store.addRepo(makeRepo())
@@ -2366,7 +2384,12 @@ describe('Store', () => {
const updated = store.updateRepo('r1', { sourceControlAi: 'bad' as never })
expect(updated!.sourceControlAi).toEqual({
instructionsByOperation: { commitMessage: 'Keep me' }
instructionsByOperation: { commitMessage: 'Keep me' },
actionOverrides: {
commitMessage: {
commandInputTemplate: '{basePrompt}\n\nKeep me'
}
}
})
})
+13 -4
View File
@@ -2576,9 +2576,8 @@ export class Store {
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'projectGroupId'
| 'projectGroupOrder'
| 'sourceControlAi'
>
>
> & { sourceControlAi?: Repo['sourceControlAi'] | null }
): Repo | null {
const repo = this.state.repos.find((r) => r.id === id)
if (!repo) {
@@ -2628,7 +2627,10 @@ export class Store {
// time visibility changes so later hide/show choices keep legacy safety.
repo.externalWorktreeVisibilityLegacy = externalWorktreeVisibilityLegacy
}
if ('sourceControlAi' in sanitizedUpdates && sanitizedUpdates.sourceControlAi === undefined) {
if (
'sourceControlAi' in sanitizedUpdates &&
(sanitizedUpdates.sourceControlAi === undefined || sanitizedUpdates.sourceControlAi === null)
) {
delete repo.sourceControlAi
delete sanitizedUpdates.sourceControlAi
} else if ('sourceControlAi' in sanitizedUpdates) {
@@ -2647,9 +2649,15 @@ export class Store {
}
private hydrateRepo(repo: Repo): Repo {
const { repoIcon: rawRepoIcon, upstream: rawUpstream, ...repoWithoutIcon } = repo
const {
repoIcon: rawRepoIcon,
upstream: rawUpstream,
sourceControlAi: rawSourceControlAi,
...repoWithoutIcon
} = repo
const repoIcon = sanitizeRepoIcon(rawRepoIcon)
const upstream = sanitizeRepoUpstream(rawUpstream)
const sourceControlAi = normalizeRepoSourceControlAiOverrides(rawSourceControlAi)
const gitUsername = isFolderRepo(repo)
? ''
: (this.gitUsernameCache.get(repo.path) ??
@@ -2663,6 +2671,7 @@ export class Store {
...repoWithoutIcon,
...(repoIcon !== undefined ? { repoIcon } : {}),
...(upstream !== undefined ? { upstream } : {}),
...(sourceControlAi !== undefined ? { sourceControlAi } : {}),
kind: isFolderRepo(repo) ? 'folder' : 'git',
gitUsername,
hookSettings: {
+4 -2
View File
@@ -6748,9 +6748,8 @@ export class OrcaRuntimeService {
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'projectGroupId'
| 'projectGroupOrder'
| 'sourceControlAi'
>
>
> & { sourceControlAi?: Repo['sourceControlAi'] | null }
): Promise<Repo> {
if (!this.store) {
throw new Error('runtime_unavailable')
@@ -6760,6 +6759,9 @@ export class OrcaRuntimeService {
if ('worktreeBasePath' in updates && updates.worktreeBasePath === undefined) {
sanitizedUpdates.worktreeBasePath = undefined
}
if ('sourceControlAi' in updates && updates.sourceControlAi === null) {
sanitizedUpdates.sourceControlAi = null
}
const updated = this.store.updateRepo(repo.id, sanitizedUpdates)
if (!updated) {
throw new Error('repo_not_found')
+5 -1
View File
@@ -34,7 +34,11 @@ const RepoSourceControlAiOverrides = z
.unknown()
.optional()
.transform((value) =>
value === undefined ? undefined : normalizeRepoSourceControlAiOverrides(value)
value === undefined
? undefined
: value === null
? null
: normalizeRepoSourceControlAiOverrides(value)
)
const RepoBadgeColor = z
+1 -2
View File
@@ -737,9 +737,8 @@ export type PreloadApi = {
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'projectGroupId'
| 'projectGroupOrder'
| 'sourceControlAi'
>
>
> & { sourceControlAi?: Repo['sourceControlAi'] | null }
}) => Promise<Repo>
pickFolder: () => Promise<string | null>
pickDirectory: () => Promise<string | null>
@@ -102,15 +102,16 @@ import { gitLabPipelineJobsToPRChecks } from '../../../../shared/gitlab-pipeline
import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
import { SourceControlAgentActionDialog } from './SourceControlAgentActionDialog'
import { readSourceControlLaunchRecipeAgentId } from '@/lib/source-control-launch-agent-selection'
import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai'
import {
normalizeSourceControlAiSettings,
resolveSourceControlActionRecipe
} from '../../../../shared/source-control-ai'
import {
setSourceControlActionDefault,
type SourceControlActionRecipe,
type SourceControlLaunchActionId
} from '../../../../shared/source-control-ai-actions'
import {
saveSourceControlActionRecipe,
type SourceControlAiWriteTarget
} from '../../../../shared/source-control-ai-recipe-save'
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
const RUNTIME_SSH_STATUS_REFRESH_MS = 3000
const GIT_STATUS_FAILURE_RETRY_MS = 3000
@@ -291,6 +292,7 @@ export default function ChecksPanel(): React.JSX.Element {
const repo = useRepoById(activeWorktree?.repoId ?? null)
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
const updateRepo = useAppStore((s) => s.updateRepo)
const prCache = useAppStore((s) => s.prCache)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch)
@@ -370,22 +372,33 @@ export default function ChecksPanel(): React.JSX.Element {
const saveLaunchActionDefault = useCallback(
async (
target: SourceControlAiWriteTarget,
actionId: SourceControlLaunchActionId,
recipe: SourceControlActionRecipe
): Promise<void> => {
const latestSettings = useAppStore.getState().settings
const latestSourceControlAi = normalizeSourceControlAiSettings(
latestSettings?.sourceControlAi,
latestSettings?.commitMessageAi
)
await updateSettings({
sourceControlAi: {
...latestSourceControlAi,
actions: setSourceControlActionDefault(latestSourceControlAi.actions, actionId, recipe)
}
const state = useAppStore.getState()
const latestSettings = state.settings
if (!latestSettings) {
throw new Error('Settings are not loaded.')
}
const latestRepo =
target.type === 'repo'
? (state.repos.find((candidate) => candidate.id === target.repoId) ?? null)
: null
const result = saveSourceControlActionRecipe({
target,
settings: latestSettings,
repo: latestRepo,
actionId,
recipe
})
if ('sourceControlAi' in result) {
await updateSettings({ sourceControlAi: result.sourceControlAi })
return
}
await updateRepo(result.target.repoId, result.update)
},
[updateSettings]
[updateRepo, updateSettings]
)
const asyncResultKeyRef = useRef<string>('')
const refreshRequestKeyRef = useRef<string | null>(null)
@@ -398,6 +411,10 @@ export default function ChecksPanel(): React.JSX.Element {
const branch = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : ''
const activeWorktreePath = activeWorktree?.path ?? null
const activeWorktreePushTarget = activeWorktree?.pushTarget ?? null
const activeSourceControlLaunchPlatform = resolveSourceControlLaunchPlatform({
connectionId: repo?.connectionId ?? null,
worktreePath: activeWorktreePath
})
const runtimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() || null
const repoConnectionId = repo?.connectionId?.trim() || null
const sshConnectionStatus = useAppStore((s) =>
@@ -2589,7 +2606,9 @@ export default function ChecksPanel(): React.JSX.Element {
worktreeId={activeWorktreeId}
groupId={activeWorktreeId}
connectionId={activeWorktreeId ? getConnectionId(activeWorktreeId) : null}
repoId={repo?.id ?? null}
promptDelivery="submit-after-ready"
launchPlatform={activeSourceControlLaunchPlatform}
launchSource={agentComposerState?.launchSource ?? 'task_page'}
savedAgentId={
agentComposerState
@@ -177,24 +177,28 @@ import { isCustomAgentId } from '../../../../shared/commit-message-agent-spec'
import {
DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
normalizeRepoSourceControlAiOverrides,
normalizeSourceControlAiSettings,
resolveSourceControlActionRecipe,
resolveSourceControlAiForOperation,
resolveSourceControlAiPrCreationDefaults
resolveSourceControlAiPrCreationDefaults,
type ResolvedSourceControlAiGenerationParams
} from '../../../../shared/source-control-ai'
import {
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES,
renderSourceControlActionCommandTemplate,
setSourceControlActionDefault,
type SourceControlTextActionId,
type SourceControlActionRecipe,
type SourceControlLaunchActionId
} from '../../../../shared/source-control-ai-actions'
import {
saveSourceControlActionRecipe,
type SourceControlAiWriteTarget
} from '../../../../shared/source-control-ai-recipe-save'
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../shared/commit-message-host-key'
import { getRuntimeGitScope } from '@/runtime/runtime-git-client'
import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
import { getRepositorySourceControlAiSectionId } from '@/components/settings/repository-settings-targets'
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
import {
getCommitFailureDialogWorktreeKey,
shouldShowCommitFailureDialog,
@@ -208,10 +212,6 @@ import {
} from './source-control-split-open'
import { SourceControlAgentActionDialog } from './SourceControlAgentActionDialog'
import { SourceControlTextGenerationDialog } from './SourceControlTextGenerationDialog'
import {
applyCommitMessageGenerationDefaults,
applySourceControlTextGenerationDefaults
} from './SourceControlTextGenerationDefaults'
export type SourceControlScope = 'all' | 'uncommitted'
type AbortConflictOperation = Extract<GitConflictOperation, 'merge' | 'rebase'>
@@ -242,6 +242,16 @@ function textGenerationRecipeIsConfigured(
return typeof recipe?.agentArgs === 'string' && recipe.agentArgs.trim().length > 0
}
function generationParamsToActionRecipe(
params: ResolvedSourceControlAiGenerationParams
): SourceControlActionRecipe {
return {
agentId: params.agentId,
commandInputTemplate: params.commandInputTemplate ?? '{basePrompt}',
...(params.agentArgs !== undefined ? { agentArgs: params.agentArgs } : {})
}
}
export function hasConfiguredSourceControlTextGenerationDefaults(input: {
actionId: SourceControlTextActionId
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'> | null | undefined
@@ -1369,13 +1379,6 @@ function SourceControlInner(): React.JSX.Element {
const prGenerationRequestSeqRef = useRef(0)
const prGenerationInFlightRef = useRef<Record<string, boolean>>({})
const [prGenerationRecords, setPrGenerationRecords] = useState<PullRequestGenerationRecords>({})
const sourceControlAi = useMemo(() => {
const normalized = normalizeSourceControlAiSettings(
settings?.sourceControlAi,
settings?.commitMessageAi
)
return settings ? normalized : { ...normalized, enabled: false }
}, [settings])
const getLaunchActionRecipe = useCallback(
(actionId: SourceControlLaunchActionId): SourceControlActionRecipe =>
resolveSourceControlActionRecipe({
@@ -1385,24 +1388,47 @@ function SourceControlInner(): React.JSX.Element {
}),
[activeRepo, settings]
)
const saveActionRecipeForTarget = useCallback(
async (
target: SourceControlAiWriteTarget,
actionId: SourceControlTextActionId | SourceControlLaunchActionId,
recipe: SourceControlActionRecipe,
customAgentCommand?: string
): Promise<void> => {
const state = useAppStore.getState()
const latestSettings = state.settings
if (!latestSettings) {
throw new Error('Settings are not loaded.')
}
const latestRepo =
target.type === 'repo'
? (state.repos.find((candidate) => candidate.id === target.repoId) ?? null)
: null
const result = saveSourceControlActionRecipe({
target,
settings: latestSettings,
repo: latestRepo,
actionId,
recipe,
customAgentCommand
})
if ('sourceControlAi' in result) {
await updateSettings({ sourceControlAi: result.sourceControlAi })
return
}
await updateRepo(result.target.repoId, result.update)
},
[updateRepo, updateSettings]
)
const saveLaunchActionDefault = useCallback(
async (
target: SourceControlAiWriteTarget,
actionId: SourceControlLaunchActionId,
recipe: SourceControlActionRecipe
): Promise<void> => {
const latestSettings = useAppStore.getState().settings
const latestSourceControlAi = normalizeSourceControlAiSettings(
latestSettings?.sourceControlAi,
latestSettings?.commitMessageAi
)
await updateSettings({
sourceControlAi: {
...latestSourceControlAi,
actions: setSourceControlActionDefault(latestSourceControlAi.actions, actionId, recipe)
}
})
await saveActionRecipeForTarget(target, actionId, recipe)
},
[updateSettings]
[saveActionRecipeForTarget]
)
const filterInputRef = useRef<HTMLInputElement>(null)
const commitMessage = readCommitDraftForWorktree(commitDrafts, activeWorktreeId)
@@ -1420,6 +1446,10 @@ function SourceControlInner(): React.JSX.Element {
const isFolder = activeRepo ? isFolderRepo(activeRepo) : false
const worktreePath = activeWorktree?.path ?? null
const activeSourceControlLaunchPlatform = resolveSourceControlLaunchPlatform({
connectionId: activeRepo?.connectionId ?? null,
worktreePath
})
const gitIdentityDisplay = activeWorktree ? getWorktreeGitIdentityDisplay(activeWorktree) : null
const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null
const branchName = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : ''
@@ -1442,8 +1472,6 @@ function SourceControlInner(): React.JSX.Element {
: null,
[activeRepo, settings, sourceControlAiDiscoveryHostKey]
)
const effectiveCommitMessageAgentId =
resolvedCommitMessageAi?.ok === true ? resolvedCommitMessageAi.value.params.agentId : null
const resolvedPrCreationDefaults = useMemo(() => {
if (!settings) {
return DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS
@@ -1921,6 +1949,7 @@ function SourceControlInner(): React.JSX.Element {
prompt,
agentArgs: savedRecipe.agentArgs,
promptDelivery: 'submit-after-ready',
launchPlatform: activeSourceControlLaunchPlatform,
launchSource: 'source_control_recovery'
})
if (!result) {
@@ -1928,7 +1957,9 @@ function SourceControlInner(): React.JSX.Element {
return false
}
focusTerminalTabSurface(result.tabId)
if (result.tabId) {
focusTerminalTabSurface(result.tabId)
}
toast.success('Started an AI agent for the commit failure.')
return true
} finally {
@@ -1938,6 +1969,7 @@ function SourceControlInner(): React.JSX.Element {
[
activeGroupId,
activeWorktreeId,
activeSourceControlLaunchPlatform,
commitError,
commitFailureRecoveryPrompt,
getLaunchActionRecipe,
@@ -2139,18 +2171,16 @@ function SourceControlInner(): React.JSX.Element {
if (generateInFlightRef.current[activeWorktreeId]) {
return
}
if (
!overrides?.sourceControlAiResolvedParams &&
(!sourceControlAi.enabled || !effectiveCommitMessageAgentId)
) {
if (!overrides?.sourceControlAiResolvedParams && resolvedCommitMessageAi?.ok !== true) {
return
}
if (
!overrides?.sourceControlAiResolvedParams &&
isCustomAgentId(effectiveCommitMessageAgentId)
resolvedCommitMessageAi?.ok === true &&
isCustomAgentId(resolvedCommitMessageAi.value.params.agentId)
) {
const command = sourceControlAi.customAgentCommand?.trim() ?? ''
const command = resolvedCommitMessageAi.value.params.customAgentCommand?.trim() ?? ''
if (!command) {
setGenerateErrors((prev) => ({
...prev,
@@ -2213,7 +2243,7 @@ function SourceControlInner(): React.JSX.Element {
generateInFlightRef.current[activeWorktreeId] = false
}
},
[activeWorktreeId, effectiveCommitMessageAgentId, sourceControlAi, worktreePath]
[activeWorktreeId, resolvedCommitMessageAi, worktreePath]
)
const handleGenerateCommitMessageClick = useCallback((): void => {
@@ -2248,44 +2278,34 @@ function SourceControlInner(): React.JSX.Element {
const handleSaveCommitMessageGenerationDefaults = useCallback(
async (
target: SourceControlAiWriteTarget,
params: NonNullable<RuntimeGenerateCommitMessageOverrides['sourceControlAiResolvedParams']>
): Promise<void> => {
const latestSettings = useAppStore.getState().settings
const latestSourceControlAi = normalizeSourceControlAiSettings(
latestSettings?.sourceControlAi,
latestSettings?.commitMessageAi
await saveActionRecipeForTarget(
target,
'commitMessage',
generationParamsToActionRecipe(params),
isCustomAgentId(params.agentId) ? params.customAgentCommand : undefined
)
await updateSettings({
sourceControlAi: applyCommitMessageGenerationDefaults(
latestSourceControlAi,
sourceControlAiDiscoveryHostKey,
params
)
})
},
[sourceControlAiDiscoveryHostKey, updateSettings]
[saveActionRecipeForTarget]
)
const handleSavePullRequestGenerationDefaults = useCallback(
async (
target: SourceControlAiWriteTarget,
params: NonNullable<
RuntimeGeneratePullRequestFieldsOverrides['sourceControlAiResolvedParams']
>
): Promise<void> => {
const latestSettings = useAppStore.getState().settings
const latestSourceControlAi = normalizeSourceControlAiSettings(
latestSettings?.sourceControlAi,
latestSettings?.commitMessageAi
await saveActionRecipeForTarget(
target,
'pullRequest',
generationParamsToActionRecipe(params),
isCustomAgentId(params.agentId) ? params.customAgentCommand : undefined
)
await updateSettings({
sourceControlAi: applySourceControlTextGenerationDefaults(
latestSourceControlAi,
'pullRequest',
params
)
})
},
[updateSettings]
[saveActionRecipeForTarget]
)
// Why: a single dispatcher for every remote-only action the split button or
@@ -2791,6 +2811,7 @@ function SourceControlInner(): React.JSX.Element {
worktreePath: worktreePath ?? '',
branch: branchName,
eligibility: hostedReviewCreation,
repo: activeRepo ?? null,
settings,
submitting: isCreatingPr,
prCreationDefaults: resolvedPrCreationDefaults,
@@ -3034,6 +3055,7 @@ function SourceControlInner(): React.JSX.Element {
])
const hasUnstagedChanges = grouped.unstaged.length > 0 || grouped.untracked.length > 0
const hasStageableChanges = hasUnstagedChanges
const hasPartiallyStagedChanges = useMemo(() => {
if (grouped.staged.length === 0 || grouped.unstaged.length === 0) {
return false
@@ -3046,6 +3068,7 @@ function SourceControlInner(): React.JSX.Element {
const action = resolvePrimaryAction({
stagedCount: grouped.staged.length,
hasUnstagedChanges,
hasStageableChanges,
hasPartiallyStagedChanges,
hasMessage: commitMessage.trim().length > 0,
hasUnresolvedConflicts: unresolvedConflicts.length > 0,
@@ -3069,6 +3092,7 @@ function SourceControlInner(): React.JSX.Element {
}, [
commitMessage,
grouped.staged.length,
hasStageableChanges,
hasUnstagedChanges,
hasPartiallyStagedChanges,
isCommitting,
@@ -3091,6 +3115,7 @@ function SourceControlInner(): React.JSX.Element {
resolveDropdownItems({
stagedCount: grouped.staged.length,
hasUnstagedChanges,
hasStageableChanges,
hasPartiallyStagedChanges,
hasMessage: commitMessage.trim().length > 0,
hasUnresolvedConflicts: unresolvedConflicts.length > 0,
@@ -3110,6 +3135,7 @@ function SourceControlInner(): React.JSX.Element {
[
commitMessage,
grouped.staged.length,
hasStageableChanges,
hasUnstagedChanges,
hasPartiallyStagedChanges,
isCommitting,
@@ -4572,6 +4598,8 @@ function SourceControlInner(): React.JSX.Element {
<CommitArea
worktreeId={activeWorktreeId}
connectionId={activeWorktreeId ? getConnectionId(activeWorktreeId) : null}
repoId={activeRepo?.id ?? null}
launchPlatform={activeSourceControlLaunchPlatform}
commitMessage={commitMessage}
commitError={commitError}
commitFailureRecoveryPrompt={commitFailureRecoveryPrompt}
@@ -4580,16 +4608,8 @@ function SourceControlInner(): React.JSX.Element {
isFixingCommitFailureWithAI={isLaunchingCommitFailureAgent}
groupId={activeGroupId ?? activeWorktreeId}
showComposer={!(scope === 'all' && showGenericEmptyState)}
aiEnabled={sourceControlAi.enabled === true}
aiAgentConfigured={
sourceControlAi.enabled === true &&
effectiveCommitMessageAgentId !== null &&
// Why: 'custom' is configured only once the user types a command.
// Without this guard, Generate would spawn an empty command and
// fail with a confusing error.
(!isCustomAgentId(effectiveCommitMessageAgentId) ||
(sourceControlAi.customAgentCommand ?? '').trim().length > 0)
}
aiEnabled={resolvedCommitMessageAi?.ok === true}
aiAgentConfigured={resolvedCommitMessageAi?.ok === true}
isGenerating={isGenerating}
generateError={generateError}
stagedCount={grouped.staged.length}
@@ -4999,7 +5019,9 @@ function SourceControlInner(): React.JSX.Element {
worktreeId={activeWorktreeId}
groupId={activeGroupId ?? activeWorktreeId}
connectionId={activeWorktreeId ? getConnectionId(activeWorktreeId) : null}
repoId={activeRepo?.id ?? null}
promptDelivery="submit-after-ready"
launchPlatform={activeSourceControlLaunchPlatform}
launchSource="conflict_resolution"
savedAgentId={readSourceControlLaunchRecipeAgentId(
getLaunchActionRecipe('resolveConflicts')
@@ -5398,6 +5420,8 @@ type CommitFailureFixSplitButtonProps = {
worktreeId: string | null
groupId: string | null
connectionId?: string | null
repoId?: string | null
launchPlatform?: NodeJS.Platform
prompt: string | null
isLaunching: boolean
variant: React.ComponentProps<typeof Button>['variant']
@@ -5409,6 +5433,7 @@ type CommitFailureFixSplitButtonProps = {
savedCommandInputTemplate?: string | null
savedAgentArgs?: string | null
onSaveAgentDefault?: (
target: SourceControlAiWriteTarget,
actionId: SourceControlLaunchActionId,
recipe: SourceControlActionRecipe
) => void | Promise<void>
@@ -5422,6 +5447,8 @@ function CommitFailureFixSplitButton({
worktreeId,
groupId,
connectionId,
repoId,
launchPlatform,
prompt,
isLaunching,
variant,
@@ -5501,7 +5528,9 @@ function CommitFailureFixSplitButton({
worktreeId={worktreeId}
groupId={groupId}
connectionId={connectionId}
repoId={repoId}
promptDelivery="submit-after-ready"
launchPlatform={launchPlatform}
launchSource="source_control_recovery"
savedAgentId={savedAgentId}
savedCommandInputTemplate={savedCommandInputTemplate}
@@ -5531,6 +5560,8 @@ type CommitAreaProps = {
worktreeId: string | null
groupId: string | null
connectionId?: string | null
repoId?: string | null
launchPlatform?: NodeJS.Platform
commitMessage: string
commitError: string | null
commitFailureRecoveryPrompt: string | null
@@ -5554,6 +5585,7 @@ type CommitAreaProps = {
onGenerate: () => void
onCancelGenerate: () => void
onSaveLaunchActionDefault?: (
target: SourceControlAiWriteTarget,
actionId: SourceControlLaunchActionId,
recipe: SourceControlActionRecipe
) => void | Promise<void>
@@ -5567,6 +5599,8 @@ export function CommitArea({
worktreeId,
groupId,
connectionId,
repoId,
launchPlatform,
commitMessage,
commitError,
commitFailureRecoveryPrompt,
@@ -5919,6 +5953,8 @@ export function CommitArea({
worktreeId={worktreeId}
groupId={groupId}
connectionId={connectionId}
repoId={repoId}
launchPlatform={launchPlatform}
prompt={commitFailureRecoveryPrompt}
isLaunching={isFixingCommitFailureWithAI}
variant="secondary"
@@ -5969,6 +6005,8 @@ export function CommitArea({
worktreeId={worktreeId}
groupId={groupId}
connectionId={connectionId}
repoId={repoId}
launchPlatform={launchPlatform}
prompt={commitFailureRecoveryPrompt}
isLaunching={isFixingCommitFailureWithAI}
variant="default"
@@ -18,6 +18,7 @@ import {
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
import type { TuiAgent } from '../../../../shared/types'
import type { LaunchSource } from '../../../../shared/telemetry-events'
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
import { planSourceControlAgentActionLaunch } from '@/lib/source-control-agent-action-plan'
import { pickSourceControlLaunchAgent } from '@/lib/source-control-launch-agent-selection'
import { toast } from 'sonner'
@@ -38,10 +39,13 @@ export type SourceControlAgentActionDialogProps = {
worktreeId?: string | null
groupId?: string | null
connectionId?: string | null
repoId?: string | null
promptDelivery?: 'auto-submit' | 'draft' | 'submit-after-ready'
launchPlatform?: NodeJS.Platform
launchSource: LaunchSource
savedAgentId?: TuiAgent | null
onSaveAgentDefault?: (
target: SourceControlAiWriteTarget,
actionId: SourceControlLaunchActionId,
recipe: SourceControlActionRecipe
) => void | Promise<void>
@@ -77,7 +81,9 @@ export function SourceControlAgentActionDialog({
worktreeId,
groupId,
connectionId,
repoId,
promptDelivery = 'submit-after-ready',
launchPlatform,
launchSource,
savedAgentId,
onSaveAgentDefault,
@@ -100,7 +106,18 @@ export function SourceControlAgentActionDialog({
status: 'idle'
})
const [isStarting, setIsStarting] = useState(false)
const [saveAgentDefault, setSaveAgentDefault] = useState(true)
const saveTargets = useMemo(() => {
const targets = [{ value: 'none', label: "Don't save" }]
if (repoId) {
targets.push({ value: 'repo', label: 'Save for this repository only' })
}
targets.push({
value: 'global',
label: repoId ? 'Save as default for all repositories' : 'Save as global default'
})
return targets
}, [repoId])
const [saveTargetValue, setSaveTargetValue] = useState(repoId ? 'repo' : 'global')
const disabledAgents = settings?.disabledTuiAgents
const connectionUnavailable = Boolean(worktreeId && connectionId === undefined)
@@ -131,6 +148,7 @@ export function SourceControlAgentActionDialog({
setCommandTemplate(savedCommandInputTemplate ?? '{basePrompt}')
setAgentArgs(savedAgentArgs ?? '')
setSelectedAgent(savedAgentId ?? null)
setSaveTargetValue(repoId ? 'repo' : 'global')
let stale = false
void refreshDetectedAgents().then((nextAgents) => {
if (stale) {
@@ -163,6 +181,7 @@ export function SourceControlAgentActionDialog({
savedAgentId,
savedAgentArgs,
savedCommandInputTemplate,
repoId,
settings?.defaultTuiAgent
])
@@ -170,11 +189,11 @@ export function SourceControlAgentActionDialog({
(nextOpen: boolean) => {
if (!nextOpen) {
setDeliveryPlan({ status: 'idle' })
setSaveAgentDefault(true)
setSaveTargetValue(repoId ? 'repo' : 'global')
}
onOpenChange(nextOpen)
},
[onOpenChange]
[onOpenChange, repoId]
)
const enabledDetectedAgents = useMemo(
@@ -217,7 +236,8 @@ export function SourceControlAgentActionDialog({
promptDelivery,
detectedAgents: currentDetectedAgents,
disabledAgents: useAppStore.getState().settings?.disabledTuiAgents,
cmdOverrides: useAppStore.getState().settings?.agentCmdOverrides
cmdOverrides: useAppStore.getState().settings?.agentCmdOverrides,
platform: launchPlatform
})
if (!result.ok) {
return { status: 'error', error: result.error }
@@ -235,7 +255,8 @@ export function SourceControlAgentActionDialog({
connectionUnavailable,
promptDelivery,
refreshDetectedAgents,
selectedAgent
selectedAgent,
launchPlatform
]
)
@@ -272,6 +293,7 @@ export function SourceControlAgentActionDialog({
prompt: trimmedCommandInput,
agentArgs,
promptDelivery,
launchPlatform,
launchSource
})
launched = Boolean(result)
@@ -283,8 +305,14 @@ export function SourceControlAgentActionDialog({
toast.error('Could not start the selected agent.')
return
}
if (saveAgentDefault && onSaveAgentDefault) {
await onSaveAgentDefault(actionId, {
const saveTarget =
saveTargetValue === 'repo' && repoId
? ({ type: 'repo', repoId } as const)
: saveTargetValue === 'global'
? ({ type: 'global' } as const)
: null
if (saveTarget && onSaveAgentDefault) {
await onSaveAgentDefault(saveTarget, actionId, {
agentId: selectedAgent,
commandInputTemplate: commandTemplate,
agentArgs
@@ -304,13 +332,15 @@ export function SourceControlAgentActionDialog({
groupId,
isStarting,
launchSource,
launchPlatform,
handleOpenChange,
onLaunched,
onSaveAgentDefault,
onStart,
promptDelivery,
refreshDetectedAgents,
saveAgentDefault,
repoId,
saveTargetValue,
selectedAgent,
trimmedCommandInput,
worktreeId
@@ -342,7 +372,8 @@ export function SourceControlAgentActionDialog({
commandTemplate={commandTemplate}
savedCommandInputTemplate={savedCommandInputTemplate}
baseCommandInput={baseCommandInput}
saveAgentDefault={saveAgentDefault}
saveTargetValue={saveTargetValue}
saveTargets={saveTargets}
canSaveAgentDefault={Boolean(onSaveAgentDefault)}
deliveryPlan={deliveryPlan}
canStart={canStart}
@@ -360,7 +391,7 @@ export function SourceControlAgentActionDialog({
setCommandTemplate(value)
setDeliveryPlan({ status: 'idle' })
}}
onSaveAgentDefaultChange={setSaveAgentDefault}
onSaveAgentDefaultChange={setSaveTargetValue}
onOpenSettings={onOpenSettings}
onStart={() => void handleStart()}
/>
@@ -5,7 +5,14 @@ import { Button } from '@/components/ui/button'
import { DialogFooter } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { getAgentLabel, type AgentCatalogEntry } from '@/lib/agent-catalog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import type { AgentCatalogEntry } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
import type { SourceControlLaunchActionId } from '../../../../shared/source-control-ai-actions'
import type { TuiAgent } from '../../../../shared/types'
@@ -27,7 +34,8 @@ type SourceControlAgentActionDialogFormProps = {
commandTemplate: string
savedCommandInputTemplate?: string | null
baseCommandInput: string
saveAgentDefault: boolean
saveTargetValue: string
saveTargets: { value: string; label: string }[]
canSaveAgentDefault: boolean
deliveryPlan: SourceControlAgentActionDeliveryPlanState
canStart: boolean
@@ -36,7 +44,7 @@ type SourceControlAgentActionDialogFormProps = {
onSelectedAgentChange: (agent: TuiAgent | null) => void
onAgentArgsChange: (value: string) => void
onCommandTemplateChange: (value: string) => void
onSaveAgentDefaultChange: (value: boolean) => void
onSaveAgentDefaultChange: (value: string) => void
onOpenSettings?: () => void
onStart: () => void
}
@@ -52,7 +60,8 @@ export function SourceControlAgentActionDialogForm({
commandTemplate,
savedCommandInputTemplate,
baseCommandInput,
saveAgentDefault,
saveTargetValue,
saveTargets,
canSaveAgentDefault,
deliveryPlan,
canStart,
@@ -145,16 +154,21 @@ export function SourceControlAgentActionDialogForm({
</div>
{canSaveAgentDefault && selectedAgent ? (
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={saveAgentDefault}
onChange={(event) => onSaveAgentDefaultChange(event.target.checked)}
className="size-3.5 rounded border-border"
/>
Save {getAgentLabel(selectedAgent)}, these CLI arguments, and this command template as
the default for this action
</label>
<div className="space-y-2">
<Label className="text-xs">Save launch recipe</Label>
<Select value={saveTargetValue} onValueChange={onSaveAgentDefaultChange}>
<SelectTrigger size="sm" className="h-8 w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{saveTargets.map((target) => (
<SelectItem key={target.value} value={target.value}>
{target.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
{deliveryPlan.status !== 'idle' ? (
@@ -8,13 +8,16 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import {
normalizeSourceControlAiSettings,
resolveSourceControlAiForOperation,
type ResolvedSourceControlAiGenerationParams
} from '../../../../shared/source-control-ai'
import type { SourceControlTextActionId } from '../../../../shared/source-control-ai-actions'
import type { GlobalSettings, Repo } from '../../../../shared/types'
import { SourceControlTextGenerationDialogForm } from './SourceControlTextGenerationDialogForm'
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
import {
SourceControlTextGenerationDialogForm,
type SourceControlTextGenerationSaveTarget
} from './SourceControlTextGenerationDialogForm'
export { buildCommitMessageGenerationParams } from './SourceControlTextGenerationParams'
@@ -22,10 +25,13 @@ type SourceControlTextGenerationBaseDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
settings: GlobalSettings | null
repo?: Pick<Repo, 'sourceControlAi'> | null
repo?: Pick<Repo, 'id' | 'sourceControlAi'> | null
discoveryHostKey: string
onGenerate: (params: ResolvedSourceControlAiGenerationParams) => void
onSaveDefaults: (params: ResolvedSourceControlAiGenerationParams) => Promise<void> | void
onSaveDefaults: (
target: SourceControlAiWriteTarget,
params: ResolvedSourceControlAiGenerationParams
) => Promise<void> | void
}
type SourceControlTextGenerationDialogProps = SourceControlTextGenerationBaseDialogProps & {
@@ -61,13 +67,32 @@ export function SourceControlTextGenerationDialog({
[actionId, discoveryHostKey, repo, settings]
)
const baseParams = resolved.ok ? resolved.value.params : null
const sourceControlAi = useMemo(
() =>
settings
? normalizeSourceControlAiSettings(settings.sourceControlAi, settings.commitMessageAi)
: null,
[settings]
)
const recipeLabel =
actionId === 'commitMessage'
? 'commit-message recipe'
: actionId === 'pullRequest'
? 'hosted-review recipe'
: 'branch-name recipe'
const saveTargets: SourceControlTextGenerationSaveTarget[] = repo?.id
? [
{
target: { type: 'repo', repoId: repo.id },
label: 'Save for this repository only',
successMessage: `Saved ${recipeLabel} for this repository.`
},
{
target: { type: 'global' },
label: 'Save as default for all repositories',
successMessage: `Saved ${recipeLabel} as a global default.`
}
]
: [
{
target: { type: 'global' },
label: 'Save as global default',
successMessage: `Saved ${recipeLabel} as a global default.`
}
]
const formKey = open
? JSON.stringify([
actionId,
@@ -99,7 +124,7 @@ export function SourceControlTextGenerationDialog({
generateLabel={generateLabel}
settings={settings}
baseParams={baseParams}
sourceControlAi={sourceControlAi}
saveTargets={saveTargets}
onGenerate={onGenerate}
onOpenChange={onOpenChange}
onSaveDefaults={onSaveDefaults}
@@ -20,7 +20,7 @@ import {
} from '../../../../shared/commit-message-agent-spec'
import type { ResolvedSourceControlAiGenerationParams } from '../../../../shared/source-control-ai'
import type { SourceControlTextActionId } from '../../../../shared/source-control-ai-actions'
import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types'
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
import type { GlobalSettings, TuiAgent } from '../../../../shared/types'
import { toast } from 'sonner'
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
@@ -36,15 +36,24 @@ type PlanState =
| { status: 'success'; commandLabel: string; delivery: string; caveat: string }
| { status: 'error'; error: string }
export type SourceControlTextGenerationSaveTarget = {
target: SourceControlAiWriteTarget
label: string
successMessage: string
}
type SourceControlTextGenerationDialogFormProps = {
actionId: SourceControlTextActionId
generateLabel: string
settings: GlobalSettings | null
baseParams: ResolvedSourceControlAiGenerationParams | null
sourceControlAi: SourceControlAiSettings | null
saveTargets: SourceControlTextGenerationSaveTarget[]
onGenerate: (params: ResolvedSourceControlAiGenerationParams) => void
onOpenChange: (open: boolean) => void
onSaveDefaults: (params: ResolvedSourceControlAiGenerationParams) => Promise<void> | void
onSaveDefaults: (
target: SourceControlAiWriteTarget,
params: ResolvedSourceControlAiGenerationParams
) => Promise<void> | void
}
function agentLabel(agentId: TuiAgent): string {
@@ -56,15 +65,14 @@ export function SourceControlTextGenerationDialogForm({
generateLabel,
settings,
baseParams,
sourceControlAi,
saveTargets,
onGenerate,
onOpenChange,
onSaveDefaults
}: SourceControlTextGenerationDialogFormProps): React.JSX.Element {
const capabilities = useMemo(listCommitMessageAgentCapabilities, [])
const showCustomAgent = Boolean(
baseParams &&
(isCustomAgentId(baseParams.agentId) || sourceControlAi?.customAgentCommand.trim())
baseParams && (isCustomAgentId(baseParams.agentId) || baseParams.customAgentCommand?.trim())
)
const [agentId, setAgentId] = useState<CommitMessageGenerationAgentChoice>(
baseParams?.agentId ?? ''
@@ -74,7 +82,7 @@ export function SourceControlTextGenerationDialogForm({
)
const [agentArgs, setAgentArgs] = useState(baseParams?.agentArgs ?? '')
const [plan, setPlan] = useState<PlanState>({ status: 'idle' })
const [saving, setSaving] = useState(false)
const [savingTargetKey, setSavingTargetKey] = useState<string | null>(null)
const commandTemplateId = `source-control-${actionId}-command-template`
const params = buildCommitMessageGenerationParams({
@@ -83,10 +91,11 @@ export function SourceControlTextGenerationDialogForm({
agentArgs,
baseParams,
settings,
customAgentCommand: sourceControlAi?.customAgentCommand
customAgentCommand: baseParams?.customAgentCommand
})
const paramsPlanResult = params ? planSourceControlTextGeneration(actionId, params) : null
const canRunGeneration = Boolean(params && paramsPlanResult?.ok)
const saving = savingTargetKey !== null
const handlePlan = (): void => {
if (!params || !paramsPlanResult) {
@@ -106,29 +115,30 @@ export function SourceControlTextGenerationDialogForm({
}
const saveCurrentDefaults = useCallback(
async (options: { showToast: boolean; showErrors: boolean }): Promise<boolean> => {
async (
saveTarget: SourceControlTextGenerationSaveTarget,
options: { showToast: boolean; showErrors: boolean }
): Promise<boolean> => {
if (!params || saving || !paramsPlanResult?.ok) {
if (options.showErrors && paramsPlanResult && !paramsPlanResult.ok) {
setPlan({ status: 'error', error: paramsPlanResult.error })
}
return false
}
setSaving(true)
const targetKey =
saveTarget.target.type === 'repo' ? `repo:${saveTarget.target.repoId}` : 'global'
setSavingTargetKey(targetKey)
try {
await onSaveDefaults(params)
await onSaveDefaults(saveTarget.target, params)
if (options.showToast) {
toast.success(
actionId === 'commitMessage'
? 'Saved commit-message recipe.'
: 'Saved hosted-review recipe.'
)
toast.success(saveTarget.successMessage)
}
return true
} finally {
setSaving(false)
setSavingTargetKey(null)
}
},
[actionId, onSaveDefaults, params, paramsPlanResult, saving]
[onSaveDefaults, params, paramsPlanResult, saving]
)
const handleGenerate = (): void => {
@@ -142,8 +152,10 @@ export function SourceControlTextGenerationDialogForm({
onOpenChange(false)
}
const handleSaveDefaults = async (): Promise<void> => {
await saveCurrentDefaults({ showToast: true, showErrors: true })
const handleSaveDefaults = async (
saveTarget: SourceControlTextGenerationSaveTarget
): Promise<void> => {
await saveCurrentDefaults(saveTarget, { showToast: true, showErrors: true })
}
return (
@@ -260,16 +272,27 @@ export function SourceControlTextGenerationDialogForm({
<CheckCircle2 className="size-4" />
Check generation
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={!canRunGeneration || saving}
onClick={() => void handleSaveDefaults()}
>
{saving ? <RefreshCw className="size-4 animate-spin" /> : <Save className="size-4" />}
Save as default
</Button>
{saveTargets.map((saveTarget) => {
const targetKey =
saveTarget.target.type === 'repo' ? `repo:${saveTarget.target.repoId}` : 'global'
return (
<Button
key={targetKey}
type="button"
variant="outline"
size="sm"
disabled={!canRunGeneration || saving}
onClick={() => void handleSaveDefaults(saveTarget)}
>
{savingTargetKey === targetKey ? (
<RefreshCw className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
{saveTarget.label}
</Button>
)
})}
<Button
type="button"
size="sm"
@@ -47,12 +47,14 @@ export function buildCommitMessageGenerationParams(args: {
? args.baseParams.thinkingLevel
: model?.defaultThinkingLevel
const agentCommandOverride = args.settings?.agentCmdOverrides?.[args.agentId]?.trim()
const customAgentCommand = args.baseParams?.customAgentCommand ?? args.customAgentCommand
return {
agentId: args.agentId,
model: modelId,
...(thinkingLevel ? { thinkingLevel } : {}),
commandInputTemplate: args.commandTemplate,
...(args.agentArgs !== undefined ? { agentArgs: args.agentArgs } : {}),
...(customAgentCommand ? { customAgentCommand } : {}),
...(agentCommandOverride ? { agentCommandOverride } : {})
}
}
@@ -19,7 +19,6 @@ import { normalizeHostedReviewBaseRef } from '../../../../shared/hosted-review-r
import type { BaseRefSearchResult } from '../../../../shared/types'
import {
DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
normalizeSourceControlAiSettings,
resolveSourceControlAiForOperation
} from '../../../../shared/source-control-ai'
import type { SourceControlAiPrCreationDefaults } from '../../../../shared/source-control-ai-types'
@@ -108,13 +107,6 @@ export function useCreatePullRequestDialogFields({
onBranchChangedByGeneration,
generation
}: UseCreatePullRequestDialogFieldsOptions) {
const normalizedSourceControlAi = normalizeSourceControlAiSettings(
settings?.sourceControlAi,
settings?.commitMessageAi
)
const sourceControlAi = settings
? normalizedSourceControlAi
: { ...normalizedSourceControlAi, enabled: false }
const resolvedPullRequestAi = settings
? resolveSourceControlAiForOperation({
settings,
@@ -459,7 +451,7 @@ export function useCreatePullRequestDialogFields({
])
return {
aiGenerationEnabled: sourceControlAi.enabled === true,
aiGenerationEnabled: resolvedPullRequestAi?.ok === true,
base,
setBase: setUserBase,
title,
@@ -629,9 +629,18 @@ export function CommitMessageAiPane({
if (
config.enabled &&
matchesSettingsSearch(searchQuery, {
title: 'PR creation defaults',
description: 'Defaults used when the Create PR composer opens.',
keywords: ['pull request', 'pr', 'draft', 'template', 'generate', 'open']
title: 'Hosted-review creation defaults',
description: 'Defaults used when the hosted-review composer opens.',
keywords: [
'hosted review',
'pull request',
'merge request',
'pr',
'draft',
'template',
'generate',
'open'
]
})
) {
const prDefaults = config.prCreationDefaults ?? {}
@@ -647,32 +656,41 @@ export function CommitMessageAiPane({
},
{
key: 'useTemplate',
label: 'Use PR template when available',
label: 'Use review template when available',
description: 'Prefer repository pull request templates when no description is set.'
},
{
key: 'generateDetailsOnOpen',
label: 'Generate details when opening Create PR',
description: 'Run pull-request detail generation once when the composer opens.'
description: 'Run hosted-review detail generation once when the composer opens.'
},
{
key: 'openAfterCreate',
label: 'Open PR after creation',
label: 'Open hosted review after creation',
description: 'Open the created hosted review in your browser after submit.'
}
]
sections.push(
<SearchableSetting
key="pr-creation-defaults"
title="PR creation defaults"
description="Defaults used when the Create PR composer opens."
keywords={['pull request', 'pr', 'draft', 'template', 'generate', 'open']}
title="Hosted-review creation defaults"
description="Defaults used when the hosted-review composer opens."
keywords={[
'hosted review',
'pull request',
'merge request',
'pr',
'draft',
'template',
'generate',
'open'
]}
className="space-y-3 px-1 py-2"
>
<div className="space-y-0.5">
<Label>PR creation defaults</Label>
<Label>Hosted-review creation defaults</Label>
<p className="text-xs text-muted-foreground">
Provider-neutral defaults for the Create PR composer.
Used by repositories that inherit global hosted-review defaults.
</p>
</div>
<div className="space-y-2">
@@ -705,9 +723,9 @@ export function CommitMessageAiPane({
className="space-y-4 border-t border-border/40 pt-4"
>
<div className="space-y-0.5">
<h3 className="text-sm font-semibold">Source Control AI</h3>
<h3 className="text-sm font-semibold">Source Control AI defaults</h3>
<p className="text-xs text-muted-foreground">
Configure the agent and command template behind each Source Control AI button.
Used by repositories that have not customized Source Control AI.
</p>
</div>
{sections}
@@ -21,13 +21,17 @@ import { RepositoryIconPicker } from './RepositoryIconPicker'
import { getRepositoryPaneSearchEntries } from './repository-search'
export { getRepositoryPaneSearchEntries }
type RepositoryPaneRepoUpdate = Omit<Partial<Repo>, 'sourceControlAi'> & {
sourceControlAi?: Repo['sourceControlAi'] | null
}
type RepositoryPaneProps = {
repo: Repo
yamlHooks: OrcaHooks | null
hasHooksFile: boolean
hooksInspectionReady: boolean
mayNeedUpdate: boolean
updateRepo: (repoId: string, updates: Partial<Repo>) => void
updateRepo: (repoId: string, updates: RepositoryPaneRepoUpdate) => void
removeProject: (repoId: string) => void
}
@@ -0,0 +1,187 @@
import type React from 'react'
import { Terminal } from 'lucide-react'
import type { TuiAgent } from '../../../../shared/types'
import { CUSTOM_AGENT_ID } from '../../../../shared/commit-message-agent-spec'
import type {
RepoSourceControlAiOverrides,
SourceControlAiSettings
} from '../../../../shared/source-control-ai-types'
import {
SOURCE_CONTROL_ACTION_IDS,
SOURCE_CONTROL_ACTION_LABELS,
type SourceControlActionId
} from '../../../../shared/source-control-ai-actions'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { AgentIcon } from '@/lib/agent-catalog'
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
import {
ACTION_DESCRIPTIONS,
SOURCE_CONTROL_TEXT_ACTION_ID_SET,
getAgentCatalogForAction,
getSourceControlAgentArgsPlaceholder
} from './source-control-action-recipe-options'
import {
ACTION_MODE_INHERIT,
ACTION_MODE_OVERRIDE,
DEFAULT_AGENT_VALUE,
actionAgentSelectValue,
actionScopeLabel,
agentArgsStateLabel,
commandTemplateStateLabel,
readInheritedAgentArgs,
readInheritedCommandTemplate,
resolveAgentArgsPlaceholderAgent
} from './repository-source-control-ai-labels'
import { hasOwnActionOverride } from './repository-source-control-ai-draft'
type RepositorySourceControlAiActionRowsProps = {
repoAi: RepoSourceControlAiOverrides
source: SourceControlAiSettings
defaultTuiAgent: TuiAgent | 'blank' | null | undefined
onActionModeChange: (actionId: SourceControlActionId, mode: string) => void
onActionAgentChange: (actionId: SourceControlActionId, value: string) => void
onActionTemplateChange: (actionId: SourceControlActionId, value: string) => void
onActionAgentArgsChange: (actionId: SourceControlActionId, value: string) => void
onAppendVariable: (actionId: SourceControlActionId, variable: string) => void
}
export function RepositorySourceControlAiActionRows({
repoAi,
source,
defaultTuiAgent,
onActionModeChange,
onActionAgentChange,
onActionTemplateChange,
onActionAgentArgsChange,
onAppendVariable
}: RepositorySourceControlAiActionRowsProps): React.JSX.Element {
return (
<div className="space-y-3">
<Label className="text-xs font-medium">Action recipes</Label>
{SOURCE_CONTROL_ACTION_IDS.map((actionId) => {
const hasOverride = hasOwnActionOverride(repoAi.actionOverrides, actionId)
const override = repoAi.actionOverrides?.[actionId]
const inheritedTemplate = readInheritedCommandTemplate(source, actionId)
const inheritedAgentArgs = readInheritedAgentArgs(source, actionId)
const templateValue =
hasOverride && typeof override?.commandInputTemplate === 'string'
? override.commandInputTemplate
: ''
const agentArgsValue =
hasOverride && typeof override?.agentArgs === 'string' ? override.agentArgs : ''
const effectiveAgent = hasOverride ? override?.agentId : source.actions?.[actionId]?.agentId
const agentArgsPlaceholder =
hasOverride && agentArgsValue
? ''
: inheritedAgentArgs ||
getSourceControlAgentArgsPlaceholder(
resolveAgentArgsPlaceholderAgent(effectiveAgent, source, actionId, defaultTuiAgent)
)
const agentOptions = getAgentCatalogForAction(actionId, effectiveAgent)
return (
<div key={actionId} className="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">
{SOURCE_CONTROL_ACTION_LABELS[actionId]}
</p>
<p className="text-[11px] text-muted-foreground">{ACTION_DESCRIPTIONS[actionId]}</p>
<div className="flex flex-wrap gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
<span>{actionScopeLabel(hasOverride)}</span>
<span>
{commandTemplateStateLabel({ hasOverride, inheritedTemplate, actionId })}
</span>
<span>
{agentArgsStateLabel({
hasOverride,
inheritedAgentArgs,
repoAgentArgs: agentArgsValue
})}
</span>
</div>
</div>
<Select
value={hasOverride ? ACTION_MODE_OVERRIDE : ACTION_MODE_INHERIT}
onValueChange={(value) => onActionModeChange(actionId, value)}
>
<SelectTrigger size="sm" className="h-8 w-full shrink-0 text-xs sm:w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ACTION_MODE_INHERIT}>Use global</SelectItem>
<SelectItem value={ACTION_MODE_OVERRIDE}>Customize</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-3 sm:grid-cols-[180px_1fr]">
<div className="space-y-2">
<Label className="text-[11px] text-muted-foreground">Agent</Label>
<Select
value={actionAgentSelectValue(effectiveAgent)}
onValueChange={(value) => onActionAgentChange(actionId, value)}
disabled={!hasOverride}
>
<SelectTrigger size="sm" className="h-8 w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_AGENT_VALUE}>
<span className="flex items-center gap-2">
<Terminal className="size-3.5 text-muted-foreground" />
Use default agent
</span>
</SelectItem>
{SOURCE_CONTROL_TEXT_ACTION_ID_SET.has(actionId) ? (
<SelectItem value={CUSTOM_AGENT_ID}>
<span className="flex items-center gap-2">
<Terminal className="size-3.5 text-muted-foreground" />
Custom command
</span>
</SelectItem>
) : null}
{agentOptions.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<span className="flex items-center gap-2">
<AgentIcon agent={agent.id} size={14} />
{agent.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Label className="text-[11px] text-muted-foreground">CLI arguments</Label>
<Input
value={agentArgsValue}
onChange={(event) => onActionAgentArgsChange(actionId, event.target.value)}
disabled={!hasOverride}
placeholder={agentArgsPlaceholder}
spellCheck={false}
className="h-8 font-mono text-xs disabled:cursor-not-allowed disabled:bg-muted/40"
/>
</div>
<div className="space-y-2">
<Label className="text-[11px] text-muted-foreground">Command template</Label>
<textarea
rows={3}
value={templateValue}
onChange={(event) => onActionTemplateChange(actionId, event.target.value)}
disabled={!hasOverride}
placeholder={inheritedTemplate}
spellCheck={false}
className="w-full resize-y rounded-md border border-border bg-background px-2.5 py-2 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:bg-muted/40"
/>
<SourceControlActionVariableChips
actionId={actionId}
disabled={!hasOverride}
onInsert={(variable) => onAppendVariable(actionId, variable)}
/>
</div>
</div>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,61 @@
import type React from 'react'
import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import {
CUSTOM_COMMAND_MODE_INHERIT,
CUSTOM_COMMAND_MODE_REPO
} from './repository-source-control-ai-labels'
type RepositorySourceControlAiCustomCommandProps = {
value: string | undefined
source: SourceControlAiSettings
onChange: (value: string | undefined) => void
}
export function RepositorySourceControlAiCustomCommand({
value,
source,
onChange
}: RepositorySourceControlAiCustomCommandProps): React.JSX.Element {
const hasRepoCommand = typeof value === 'string' && value.trim().length > 0
const mode = hasRepoCommand ? CUSTOM_COMMAND_MODE_REPO : CUSTOM_COMMAND_MODE_INHERIT
return (
<div className="space-y-2 rounded-md border border-border px-3 py-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-0.5">
<Label className="text-xs font-medium">Custom command</Label>
<p className="text-[11px] text-muted-foreground">
Repo fallback for text actions that select Custom command.
</p>
</div>
<Select
value={mode}
onValueChange={(nextMode) => {
onChange(
nextMode === CUSTOM_COMMAND_MODE_REPO
? (value ?? source.customAgentCommand)
: undefined
)
}}
>
<SelectTrigger size="sm" className="h-8 w-full text-xs sm:w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={CUSTOM_COMMAND_MODE_INHERIT}>Use global</SelectItem>
<SelectItem value={CUSTOM_COMMAND_MODE_REPO}>Repository command</SelectItem>
</SelectContent>
</Select>
</div>
<Input
value={value ?? ''}
onChange={(event) => onChange(event.target.value)}
placeholder={source.customAgentCommand || 'e.g. ollama run llama3.1 {prompt}'}
spellCheck={false}
className="h-8 font-mono text-xs"
/>
</div>
)
}
@@ -0,0 +1,52 @@
import type React from 'react'
import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
type RepositorySourceControlAiEnablementProps = {
value: boolean | undefined
source: SourceControlAiSettings
onChange: (value: boolean | undefined) => void
}
function enablementValue(value: boolean | undefined): 'inherit' | 'on' | 'off' {
if (value === true) {
return 'on'
}
if (value === false) {
return 'off'
}
return 'inherit'
}
export function RepositorySourceControlAiEnablement({
value,
source,
onChange
}: RepositorySourceControlAiEnablementProps): React.JSX.Element {
return (
<div className="flex flex-col gap-2 rounded-md border border-border px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-0.5">
<Label className="text-xs font-medium">Source Control AI enabled</Label>
<p className="text-[11px] text-muted-foreground">
Global default is {source.enabled ? 'On' : 'Off'}.
</p>
</div>
<Select
value={enablementValue(value)}
onValueChange={(nextValue) => {
onChange(nextValue === 'inherit' ? undefined : nextValue === 'on')
}}
>
<SelectTrigger size="sm" className="h-8 w-full text-xs sm:w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">Use global</SelectItem>
<SelectItem value="on">On</SelectItem>
<SelectItem value="off">Off</SelectItem>
</SelectContent>
</Select>
</div>
)
}
@@ -0,0 +1,66 @@
import type React from 'react'
import type {
RepoSourceControlAiOverrides,
SourceControlAiSettings
} from '../../../../shared/source-control-ai-types'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { triStateValue } from './repository-source-control-ai-draft'
type HostedReviewDefaultKey = keyof NonNullable<RepoSourceControlAiOverrides['prCreationDefaults']>
type RepositorySourceControlAiHostedReviewDefaultsProps = {
value: RepoSourceControlAiOverrides['prCreationDefaults']
source: SourceControlAiSettings
onChange: (key: HostedReviewDefaultKey, value: string) => void
}
const HOSTED_REVIEW_DEFAULT_ROWS: { key: HostedReviewDefaultKey; label: string }[] = [
{ key: 'draft', label: 'Draft by default' },
{ key: 'useTemplate', label: 'Use review template when available' },
{ key: 'generateDetailsOnOpen', label: 'Generate details when opening Create PR' },
{ key: 'openAfterCreate', label: 'Open hosted review after creation' }
]
export function RepositorySourceControlAiHostedReviewDefaults({
value,
source,
onChange
}: RepositorySourceControlAiHostedReviewDefaultsProps): React.JSX.Element {
return (
<div className="space-y-2">
<Label className="text-xs font-medium">Hosted-review creation defaults</Label>
<div className="space-y-2">
{HOSTED_REVIEW_DEFAULT_ROWS.map((row) => {
const inherited = source.prCreationDefaults?.[row.key] === true ? 'On' : 'Off'
return (
<div
key={row.key}
className="flex items-center justify-between gap-4 rounded-md border border-border px-3 py-2"
>
<span className="min-w-0 space-y-0.5">
<span className="block text-xs text-foreground">{row.label}</span>
<span className="block text-[11px] text-muted-foreground">
Global default is {inherited}.
</span>
</span>
<Select
value={triStateValue(value?.[row.key])}
onValueChange={(nextValue) => onChange(row.key, nextValue)}
>
<SelectTrigger size="sm" className="h-8 w-[120px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">Use global</SelectItem>
<SelectItem value="on">On</SelectItem>
<SelectItem value="off">Off</SelectItem>
</SelectContent>
</Select>
</div>
)
})}
</div>
</div>
)
}
@@ -1,177 +1,85 @@
/* eslint-disable max-lines -- Why: repo Source Control AI settings keep one
draft/save flow across action recipes and PR-default override groups. */
import { useMemo, useState } from 'react'
import { Terminal } from 'lucide-react'
import type { Repo, TuiAgent } from '../../../../shared/types'
import {
CUSTOM_AGENT_ID,
isCustomAgentId,
type CustomAgentId
} from '../../../../shared/commit-message-agent-spec'
import type {
RepoSourceControlAiOverrides,
SourceControlAiSettings
} from '../../../../shared/source-control-ai-types'
import type React from 'react'
import type { GlobalSettings, 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
normalizeSourceControlAiSettings,
resolveSourceControlActionRecipe
} from '../../../../shared/source-control-ai'
import {
SOURCE_CONTROL_ACTION_IDS,
SOURCE_CONTROL_ACTION_LABELS,
resolveSourceControlActionCommandTemplate,
type SourceControlActionId
} from '../../../../shared/source-control-ai-actions'
import { Label } from '../ui/label'
import { Input } from '../ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { useAppStore } from '../../store'
import { getRepositorySourceControlAiSectionId } from './repository-settings-targets'
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 { Button } from '../ui/button'
import { useAppStore } from '../../store'
import { useMountedRef } from '@/hooks/useMountedRef'
import { AgentIcon } from '@/lib/agent-catalog'
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
import { getRepositorySourceControlAiSectionId } from './repository-settings-targets'
import { RepositorySourceControlAiActionRows } from './RepositorySourceControlAiActionRows'
import { RepositorySourceControlAiCustomCommand } from './RepositorySourceControlAiCustomCommand'
import { RepositorySourceControlAiEnablement } from './RepositorySourceControlAiEnablement'
import { RepositorySourceControlAiHostedReviewDefaults } from './RepositorySourceControlAiHostedReviewDefaults'
import {
ACTION_DESCRIPTIONS,
SOURCE_CONTROL_TEXT_ACTION_ID_SET,
getAgentCatalogForAction,
getSourceControlAgentArgsPlaceholder
} from './source-control-action-recipe-options'
createRepoAiDraftState,
dropRepoLegacyInstructionForAction,
hasOwnActionOverride,
normalizeRepoAiDraft,
resolveRepoAiDraftState,
serializeRepoAiDraft,
type RepoAiDraftState
} from './repository-source-control-ai-draft'
import {
ACTION_MODE_INHERIT,
DEFAULT_AGENT_VALUE,
completeRepoActionRecipe,
readInheritedCommandTemplate
} from './repository-source-control-ai-labels'
export {
createRepoAiDraftState,
dropRepoLegacyInstructionForAction,
resolveRepoAiDraftState
} from './repository-source-control-ai-draft'
type RepositorySourceControlAiSectionProps = {
repo: Repo
updateRepo: (repoId: string, updates: Partial<Repo>) => void | Promise<boolean>
updateRepo: (repoId: string, updates: SourceControlAiRepoUpdate) => void | Promise<boolean>
}
const ACTION_MODE_INHERIT = 'inherit'
const ACTION_MODE_OVERRIDE = 'override'
const INHERIT_AGENT_VALUE = '__inherit_agent__'
const DEFAULT_AGENT_VALUE = '__default_agent__'
type HostedReviewDefaultKey = keyof NonNullable<RepoSourceControlAiOverrides['prCreationDefaults']>
type PrDefaultKey = keyof NonNullable<RepoSourceControlAiOverrides['prCreationDefaults']>
type RepoAiDraftState = {
repoId: string
value: RepoSourceControlAiOverrides
baseSerialized: string
}
function hasOwnActionOverride(
overrides: RepoSourceControlAiOverrides['actionOverrides'],
function readCompleteRecipeForDraft(
current: RepoSourceControlAiOverrides,
settings: GlobalSettings | null,
actionId: SourceControlActionId
): boolean {
return Object.prototype.hasOwnProperty.call(overrides ?? {}, actionId)
): NonNullable<
NonNullable<RepoSourceControlAiOverrides['actionOverrides']>[SourceControlActionId]
> {
const recipe = resolveSourceControlActionRecipe({
settings,
repo: { sourceControlAi: current },
actionId
})
return completeRepoActionRecipe(recipe, actionId)
}
function triStateValue(value: boolean | null | undefined): 'inherit' | 'on' | 'off' {
if (value === true) {
return 'on'
}
if (value === false) {
return 'off'
}
return 'inherit'
}
function normalizeRepoAiDraft(
value: RepoSourceControlAiOverrides | null | undefined
): RepoSourceControlAiOverrides {
return normalizeRepoSourceControlAiOverrides(value) ?? {}
}
function serializeRepoAiDraft(value: RepoSourceControlAiOverrides): string {
return JSON.stringify(normalizeRepoAiDraft(value))
}
export function createRepoAiDraftState(
repoId: string,
value: RepoSourceControlAiOverrides
): RepoAiDraftState {
const normalized = normalizeRepoAiDraft(value)
return {
repoId,
value: normalized,
baseSerialized: serializeRepoAiDraft(normalized)
}
}
export function resolveRepoAiDraftState(
current: RepoAiDraftState,
repoId: string,
persistedRepoAi: RepoSourceControlAiOverrides,
persistedSerialized = serializeRepoAiDraft(persistedRepoAi)
): RepoAiDraftState {
const currentSerialized = serializeRepoAiDraft(current.value)
// Why: render-time draft sync relies on object identity to avoid repeating
// the same state update during server-rendered settings tests.
if (
current.repoId === repoId &&
currentSerialized === persistedSerialized &&
current.baseSerialized === persistedSerialized
) {
return current
}
if (
current.repoId !== repoId ||
currentSerialized === current.baseSerialized ||
currentSerialized === persistedSerialized
) {
return {
repoId,
value: persistedRepoAi,
baseSerialized: persistedSerialized
}
}
return current
}
function readInheritedCommandTemplate(
source: SourceControlAiSettings,
actionId: SourceControlActionId
): string {
return resolveSourceControlActionCommandTemplate(source.actions, actionId)
}
function readInheritedAgentArgs(
source: SourceControlAiSettings,
actionId: SourceControlActionId
): string {
return source.actions?.[actionId]?.agentArgs?.trim() ?? ''
}
export function dropRepoLegacyInstructionForAction(
value: RepoSourceControlAiOverrides,
actionId: SourceControlActionId
): RepoSourceControlAiOverrides {
if (!SOURCE_CONTROL_TEXT_ACTION_ID_SET.has(actionId) || !value.instructionsByOperation) {
return value
}
const instructionsByOperation = { ...value.instructionsByOperation }
delete instructionsByOperation[actionId as keyof typeof instructionsByOperation]
return {
...value,
instructionsByOperation:
Object.keys(instructionsByOperation).length > 0 ? instructionsByOperation : undefined
}
}
function actionAgentSelectValue(agentId: TuiAgent | CustomAgentId | null | undefined): string {
if (agentId === undefined) {
return INHERIT_AGENT_VALUE
}
return agentId ?? DEFAULT_AGENT_VALUE
}
function resolveAgentArgsPlaceholderAgent(
agentId: TuiAgent | CustomAgentId | null | undefined,
source: SourceControlAiSettings,
function setActionOverride(
current: RepoSourceControlAiOverrides,
actionId: SourceControlActionId,
defaultTuiAgent: TuiAgent | 'blank' | null | undefined
): TuiAgent | null {
const effectiveAgent = agentId === undefined ? source.actions?.[actionId]?.agentId : agentId
if (effectiveAgent && !isCustomAgentId(effectiveAgent)) {
return effectiveAgent
}
return defaultTuiAgent && defaultTuiAgent !== 'blank' ? defaultTuiAgent : null
recipe: NonNullable<
NonNullable<RepoSourceControlAiOverrides['actionOverrides']>[SourceControlActionId]
>
): RepoSourceControlAiOverrides {
return dropRepoLegacyInstructionForAction(
{
...current,
actionOverrides: {
...current.actionOverrides,
[actionId]: recipe
}
},
actionId
)
}
export function RepositorySourceControlAiSection({
@@ -244,10 +152,11 @@ export function RepositorySourceControlAiSection({
}
const next = normalizeRepoAiDraft(resolvedDraftState.value)
const nextSerialized = serializeRepoAiDraft(next)
const repoUpdate = toSourceControlAiRepoUpdate(next)
setIsSaving(true)
setSaveError(null)
try {
const result = await updateRepo(repo.id, { sourceControlAi: next })
const result = await updateRepo(repo.id, repoUpdate)
if (!mountedRef.current) {
return
}
@@ -255,6 +164,10 @@ export function RepositorySourceControlAiSection({
setSaveError('Failed to save Source Control AI settings.')
return
}
const savedValue =
repoUpdate.sourceControlAi === null
? {}
: (normalizeRepoSourceControlAiOverrides(repoUpdate.sourceControlAi) ?? {})
setDraftState((current) => {
if (current.repoId !== repo.id) {
return current
@@ -262,8 +175,8 @@ export function RepositorySourceControlAiSection({
const currentSerialized = serializeRepoAiDraft(current.value)
return {
repoId: repo.id,
value: currentSerialized === nextSerialized ? next : current.value,
baseSerialized: nextSerialized
value: currentSerialized === nextSerialized ? savedValue : current.value,
baseSerialized: serializeRepoAiDraft(savedValue)
}
})
} catch {
@@ -282,16 +195,26 @@ export function RepositorySourceControlAiSection({
setSaveError(null)
}
const updateEnablement = (value: boolean | undefined): void => {
updateDraftRepoAi((current) => ({ ...current, enabled: value }))
}
const updateCustomCommand = (value: string | undefined): void => {
updateDraftRepoAi((current) => ({ ...current, customAgentCommand: value }))
}
const updateActionMode = (actionId: SourceControlActionId, mode: string): void => {
updateDraftRepoAi((current) => {
const nextActionOverrides = { ...current.actionOverrides }
if (mode === ACTION_MODE_INHERIT) {
delete nextActionOverrides[actionId]
} else if (!hasOwnActionOverride(nextActionOverrides, actionId)) {
nextActionOverrides[actionId] = {
commandInputTemplate: readInheritedCommandTemplate(source, actionId),
agentArgs: readInheritedAgentArgs(source, actionId)
}
return dropRepoLegacyInstructionForAction(
{ ...current, actionOverrides: nextActionOverrides },
actionId
)
}
if (!hasOwnActionOverride(nextActionOverrides, actionId)) {
nextActionOverrides[actionId] = readCompleteRecipeForDraft(current, settings, actionId)
}
return dropRepoLegacyInstructionForAction(
{ ...current, actionOverrides: nextActionOverrides },
@@ -302,66 +225,44 @@ export function RepositorySourceControlAiSection({
const updateActionAgent = (actionId: SourceControlActionId, value: string): void => {
updateDraftRepoAi((current) => {
const nextActionOverrides = { ...current.actionOverrides }
const currentOverride = nextActionOverrides[actionId] ?? {
commandInputTemplate: readInheritedCommandTemplate(source, actionId),
agentArgs: readInheritedAgentArgs(source, actionId)
const currentRecipe =
current.actionOverrides?.[actionId] ??
readCompleteRecipeForDraft(current, settings, actionId)
const nextRecipe = {
...currentRecipe,
agentId:
value === DEFAULT_AGENT_VALUE
? null
: value === CUSTOM_AGENT_ID
? CUSTOM_AGENT_ID
: (value as TuiAgent)
}
if (value === INHERIT_AGENT_VALUE) {
const { agentId: _agentId, ...rest } = currentOverride
nextActionOverrides[actionId] = rest
} else {
nextActionOverrides[actionId] = {
...currentOverride,
agentId:
value === DEFAULT_AGENT_VALUE
? null
: value === CUSTOM_AGENT_ID
? CUSTOM_AGENT_ID
: (value as TuiAgent)
}
}
return dropRepoLegacyInstructionForAction(
{ ...current, actionOverrides: nextActionOverrides },
actionId
)
return setActionOverride(current, actionId, nextRecipe)
})
}
const updateActionTemplate = (actionId: SourceControlActionId, value: string): void => {
updateDraftRepoAi((current) =>
dropRepoLegacyInstructionForAction(
{
...current,
actionOverrides: {
...current.actionOverrides,
[actionId]: {
...current.actionOverrides?.[actionId],
commandInputTemplate: value
}
}
},
actionId
)
)
updateDraftRepoAi((current) => {
const currentRecipe =
current.actionOverrides?.[actionId] ??
readCompleteRecipeForDraft(current, settings, actionId)
return setActionOverride(current, actionId, {
...currentRecipe,
commandInputTemplate: value
})
})
}
const updateActionAgentArgs = (actionId: SourceControlActionId, value: string): void => {
updateDraftRepoAi((current) =>
dropRepoLegacyInstructionForAction(
{
...current,
actionOverrides: {
...current.actionOverrides,
[actionId]: {
...current.actionOverrides?.[actionId],
agentArgs: value
}
}
},
actionId
)
)
updateDraftRepoAi((current) => {
const currentRecipe =
current.actionOverrides?.[actionId] ??
readCompleteRecipeForDraft(current, settings, actionId)
return setActionOverride(current, actionId, {
...currentRecipe,
agentArgs: value
})
})
}
const appendVariable = (actionId: SourceControlActionId, variable: string): void => {
@@ -374,7 +275,7 @@ export function RepositorySourceControlAiSection({
updateActionTemplate(actionId, `${currentTemplate}${separator}{${variable}}`)
}
const updatePrDefault = (key: PrDefaultKey, value: string): void => {
const updateHostedReviewDefault = (key: HostedReviewDefaultKey, value: string): void => {
updateDraftRepoAi((current) => {
const nextDefaults = { ...current.prCreationDefaults }
if (value === 'inherit') {
@@ -386,13 +287,6 @@ export function RepositorySourceControlAiSection({
})
}
const prDefaultRows: { key: PrDefaultKey; label: string }[] = [
{ key: 'draft', label: 'Draft by default' },
{ key: 'useTemplate', label: 'Use PR template when available' },
{ key: 'generateDetailsOnOpen', label: 'Generate details when opening Create PR' },
{ key: 'openAfterCreate', label: 'Open PR after creation' }
]
return (
<section
id={getRepositorySourceControlAiSectionId(repo.id)}
@@ -403,8 +297,8 @@ export function RepositorySourceControlAiSection({
<div className="min-w-0 space-y-1">
<h3 className="text-sm font-semibold">Source Control AI</h3>
<p className="text-xs text-muted-foreground">
Repo-specific action recipes. Each action inherits global settings until you customize
it here.
Repository action recipes. Global settings are used until this repository customizes
them.
</p>
{saveError ? <p className="text-xs text-destructive">{saveError}</p> : null}
</div>
@@ -435,155 +329,31 @@ export function RepositorySourceControlAiSection({
</div>
</div>
<div className="space-y-3">
<Label className="text-xs font-medium">Action recipes</Label>
{SOURCE_CONTROL_ACTION_IDS.map((actionId) => {
const hasOverride = hasOwnActionOverride(repoAi.actionOverrides, actionId)
const override = repoAi.actionOverrides?.[actionId]
const inheritedTemplate = readInheritedCommandTemplate(source, actionId)
const inheritedAgentArgs = readInheritedAgentArgs(source, actionId)
const templateValue =
hasOverride && typeof override?.commandInputTemplate === 'string'
? override.commandInputTemplate
: ''
const agentArgsValue =
hasOverride && typeof override?.agentArgs === 'string' ? override.agentArgs : ''
const agentArgsPlaceholder =
inheritedAgentArgs ||
getSourceControlAgentArgsPlaceholder(
resolveAgentArgsPlaceholderAgent(
override?.agentId,
source,
actionId,
settings?.defaultTuiAgent
)
)
const agentOptions = getAgentCatalogForAction(actionId, override?.agentId)
return (
<div key={actionId} className="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">
{SOURCE_CONTROL_ACTION_LABELS[actionId]}
</p>
<p className="text-[11px] text-muted-foreground">
{ACTION_DESCRIPTIONS[actionId]}
</p>
</div>
<Select
value={hasOverride ? ACTION_MODE_OVERRIDE : ACTION_MODE_INHERIT}
onValueChange={(value) => updateActionMode(actionId, value)}
>
<SelectTrigger size="sm" className="h-8 w-full shrink-0 text-xs sm:w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ACTION_MODE_INHERIT}>Use global</SelectItem>
<SelectItem value={ACTION_MODE_OVERRIDE}>Customize</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-3 sm:grid-cols-[180px_1fr]">
<div className="space-y-2">
<Label className="text-[11px] text-muted-foreground">Agent</Label>
<Select
value={actionAgentSelectValue(override?.agentId)}
onValueChange={(value) => updateActionAgent(actionId, value)}
disabled={!hasOverride}
>
<SelectTrigger size="sm" className="h-8 w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={INHERIT_AGENT_VALUE}>
<span className="flex items-center gap-2">
<Terminal className="size-3.5 text-muted-foreground" />
Use global agent
</span>
</SelectItem>
<SelectItem value={DEFAULT_AGENT_VALUE}>
<span className="flex items-center gap-2">
<Terminal className="size-3.5 text-muted-foreground" />
Use default agent
</span>
</SelectItem>
{SOURCE_CONTROL_TEXT_ACTION_ID_SET.has(actionId) ? (
<SelectItem value={CUSTOM_AGENT_ID}>
<span className="flex items-center gap-2">
<Terminal className="size-3.5 text-muted-foreground" />
Custom command
</span>
</SelectItem>
) : null}
{agentOptions.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<span className="flex items-center gap-2">
<AgentIcon agent={agent.id} size={14} />
{agent.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Label className="text-[11px] text-muted-foreground">CLI arguments</Label>
<Input
value={agentArgsValue}
onChange={(event) => updateActionAgentArgs(actionId, event.target.value)}
disabled={!hasOverride}
placeholder={agentArgsPlaceholder}
spellCheck={false}
className="h-8 font-mono text-xs disabled:cursor-not-allowed disabled:bg-muted/40"
/>
</div>
<div className="space-y-2">
<Label className="text-[11px] text-muted-foreground">Command template</Label>
<textarea
rows={3}
value={templateValue}
onChange={(event) => updateActionTemplate(actionId, event.target.value)}
disabled={!hasOverride}
placeholder={inheritedTemplate}
spellCheck={false}
className="w-full resize-y rounded-md border border-border bg-background px-2.5 py-2 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:bg-muted/40"
/>
<SourceControlActionVariableChips
actionId={actionId}
disabled={!hasOverride}
onInsert={(variable) => appendVariable(actionId, variable)}
/>
</div>
</div>
</div>
)
})}
</div>
<div className="space-y-2">
<Label className="text-xs font-medium">PR creation defaults</Label>
<div className="space-y-2">
{prDefaultRows.map((row) => (
<div
key={row.key}
className="flex items-center justify-between gap-4 rounded-md border border-border px-3 py-2"
>
<span className="text-xs text-foreground">{row.label}</span>
<Select
value={triStateValue(repoAi.prCreationDefaults?.[row.key])}
onValueChange={(value) => updatePrDefault(row.key, value)}
>
<SelectTrigger size="sm" className="h-8 w-[120px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">Use global</SelectItem>
<SelectItem value="on">On</SelectItem>
<SelectItem value="off">Off</SelectItem>
</SelectContent>
</Select>
</div>
))}
</div>
</div>
<RepositorySourceControlAiEnablement
value={repoAi.enabled}
source={source}
onChange={updateEnablement}
/>
<RepositorySourceControlAiCustomCommand
value={repoAi.customAgentCommand}
source={source}
onChange={updateCustomCommand}
/>
<RepositorySourceControlAiActionRows
repoAi={repoAi}
source={source}
defaultTuiAgent={settings?.defaultTuiAgent}
onActionModeChange={updateActionMode}
onActionAgentChange={updateActionAgent}
onActionTemplateChange={updateActionTemplate}
onActionAgentArgsChange={updateActionAgentArgs}
onAppendVariable={appendVariable}
/>
<RepositorySourceControlAiHostedReviewDefaults
value={repoAi.prCreationDefaults}
source={source}
onChange={updateHostedReviewDefault}
/>
</section>
)
}
@@ -0,0 +1,95 @@
import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions'
import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types'
import { SOURCE_CONTROL_TEXT_ACTION_ID_SET } from './source-control-action-recipe-options'
export type RepoAiDraftState = {
repoId: string
value: RepoSourceControlAiOverrides
baseSerialized: string
}
export function hasOwnActionOverride(
overrides: RepoSourceControlAiOverrides['actionOverrides'],
actionId: SourceControlActionId
): boolean {
return Object.prototype.hasOwnProperty.call(overrides ?? {}, actionId)
}
export function triStateValue(value: boolean | null | undefined): 'inherit' | 'on' | 'off' {
if (value === true) {
return 'on'
}
if (value === false) {
return 'off'
}
return 'inherit'
}
export function normalizeRepoAiDraft(
value: RepoSourceControlAiOverrides | null | undefined
): RepoSourceControlAiOverrides {
return normalizeRepoSourceControlAiOverrides(value) ?? {}
}
export function serializeRepoAiDraft(value: RepoSourceControlAiOverrides): string {
return JSON.stringify(normalizeRepoAiDraft(value))
}
export function createRepoAiDraftState(
repoId: string,
value: RepoSourceControlAiOverrides
): RepoAiDraftState {
const normalized = normalizeRepoAiDraft(value)
return {
repoId,
value: normalized,
baseSerialized: serializeRepoAiDraft(normalized)
}
}
export function resolveRepoAiDraftState(
current: RepoAiDraftState,
repoId: string,
persistedRepoAi: RepoSourceControlAiOverrides,
persistedSerialized = serializeRepoAiDraft(persistedRepoAi)
): RepoAiDraftState {
const currentSerialized = serializeRepoAiDraft(current.value)
// Why: render-time draft sync relies on object identity to avoid repeating
// the same state update during server-rendered settings tests.
if (
current.repoId === repoId &&
currentSerialized === persistedSerialized &&
current.baseSerialized === persistedSerialized
) {
return current
}
if (
current.repoId !== repoId ||
currentSerialized === current.baseSerialized ||
currentSerialized === persistedSerialized
) {
return {
repoId,
value: persistedRepoAi,
baseSerialized: persistedSerialized
}
}
return current
}
export function dropRepoLegacyInstructionForAction(
value: RepoSourceControlAiOverrides,
actionId: SourceControlActionId
): RepoSourceControlAiOverrides {
if (!SOURCE_CONTROL_TEXT_ACTION_ID_SET.has(actionId) || !value.instructionsByOperation) {
return value
}
const instructionsByOperation = { ...value.instructionsByOperation }
delete instructionsByOperation[actionId as keyof typeof instructionsByOperation]
return {
...value,
instructionsByOperation:
Object.keys(instructionsByOperation).length > 0 ? instructionsByOperation : undefined
}
}
@@ -0,0 +1,96 @@
import type { TuiAgent } from '../../../../shared/types'
import {
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES,
resolveSourceControlActionCommandTemplate,
type SourceControlActionId,
type SourceControlActionRecipe
} from '../../../../shared/source-control-ai-actions'
import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types'
import type { CustomAgentId } from '../../../../shared/commit-message-agent-spec'
import { isCustomAgentId } from '../../../../shared/commit-message-agent-spec'
import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types'
export const ACTION_MODE_INHERIT = 'inherit'
export const ACTION_MODE_OVERRIDE = 'override'
export const DEFAULT_AGENT_VALUE = '__default_agent__'
export const CUSTOM_COMMAND_MODE_INHERIT = 'inherit'
export const CUSTOM_COMMAND_MODE_REPO = 'repo'
export function readInheritedCommandTemplate(
source: SourceControlAiSettings,
actionId: SourceControlActionId
): string {
return resolveSourceControlActionCommandTemplate(source.actions, actionId)
}
export function readInheritedAgentArgs(
source: SourceControlAiSettings,
actionId: SourceControlActionId
): string {
return source.actions?.[actionId]?.agentArgs?.trim() ?? ''
}
export function actionAgentSelectValue(
agentId: TuiAgent | CustomAgentId | null | undefined
): string {
return agentId ?? DEFAULT_AGENT_VALUE
}
export function resolveAgentArgsPlaceholderAgent(
agentId: TuiAgent | CustomAgentId | null | undefined,
source: SourceControlAiSettings,
actionId: SourceControlActionId,
defaultTuiAgent: TuiAgent | 'blank' | null | undefined
): TuiAgent | null {
const effectiveAgent = agentId === undefined ? source.actions?.[actionId]?.agentId : agentId
if (effectiveAgent && !isCustomAgentId(effectiveAgent)) {
return effectiveAgent
}
return defaultTuiAgent && defaultTuiAgent !== 'blank' ? defaultTuiAgent : null
}
export function completeRepoActionRecipe(
recipe: SourceControlActionRecipe,
actionId: SourceControlActionId
): NonNullable<
NonNullable<RepoSourceControlAiOverrides['actionOverrides']>[SourceControlActionId]
> {
const commandInputTemplate =
typeof recipe.commandInputTemplate === 'string'
? recipe.commandInputTemplate
: DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId]
const agentArgs = typeof recipe.agentArgs === 'string' ? recipe.agentArgs : ''
return {
agentId: recipe.agentId ?? null,
commandInputTemplate,
...(agentArgs ? { agentArgs } : {})
}
}
export function actionScopeLabel(hasOverride: boolean): string {
return hasOverride ? 'Customized for this repository' : 'Using global settings'
}
export function commandTemplateStateLabel(args: {
hasOverride: boolean
inheritedTemplate: string
actionId: SourceControlActionId
}): string {
if (args.hasOverride) {
return 'Repository custom prompt'
}
return args.inheritedTemplate === DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[args.actionId]
? 'Orca default prompt'
: 'Global custom prompt'
}
export function agentArgsStateLabel(args: {
hasOverride: boolean
inheritedAgentArgs: string
repoAgentArgs: string
}): string {
if (args.hasOverride) {
return args.repoAgentArgs.trim() ? 'Repository custom args' : 'No args'
}
return args.inheritedAgentArgs.trim() ? 'Global custom args' : 'No args'
}
@@ -8,6 +8,7 @@ import {
pickSourceControlLaunchAgent,
readSourceControlLaunchRecipeAgentId
} from '@/lib/source-control-launch-agent-selection'
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { useAppStore } from '@/store'
import { resolveSourceControlActionRecipe } from '../../../shared/source-control-ai'
@@ -119,6 +120,7 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis
: findGithubPrWorkspaceAttachment(store.allWorktrees(), args.repoId, args.item.number)
const targetWorktreeId = args.worktreeId ?? attachedWorkspace?.id ?? null
if (targetWorktreeId) {
const targetWorktree = store.allWorktrees().find((worktree) => worktree.id === targetWorktreeId)
const agent = await pickExistingWorktreeAgent(targetWorktreeId, savedAgentId)
if (!agent) {
return false
@@ -134,13 +136,19 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis
prompt: commandInput,
agentArgs: recipe.agentArgs,
promptDelivery: 'submit-after-ready',
launchPlatform: resolveSourceControlLaunchPlatform({
connectionId: repo?.connectionId ?? null,
worktreePath: targetWorktree?.path
}),
launchSource: args.launchSource
})
if (!result) {
toast.error('Could not build the agent launch command.')
return false
}
focusTerminalTabSurface(result.tabId)
if (result.tabId) {
focusTerminalTabSurface(result.tabId)
}
return true
}
@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('./new-workspace', () => ({ CLIENT_PLATFORM: 'darwin' }))
describe('resolveSourceControlLaunchPlatform', () => {
it('uses linux shell planning for ssh POSIX paths and local WSL paths', async () => {
const { resolveSourceControlLaunchPlatform } = await import('./source-control-launch-platform')
expect(
resolveSourceControlLaunchPlatform({
connectionId: 'ssh-1',
worktreePath: '/home/alice/repo'
})
).toBe('linux')
expect(
resolveSourceControlLaunchPlatform({
connectionId: null,
worktreePath: String.raw`\\wsl.localhost\Ubuntu\home\alice\repo`
})
).toBe('linux')
})
it('uses windows shell planning for ssh Windows paths', async () => {
const { resolveSourceControlLaunchPlatform } = await import('./source-control-launch-platform')
expect(
resolveSourceControlLaunchPlatform({
connectionId: 'ssh-1',
worktreePath: String.raw`C:\Users\alice\repo`
})
).toBe('win32')
})
})
@@ -0,0 +1,17 @@
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
import { isWslUncPath } from '../../../shared/wsl-paths'
import { CLIENT_PLATFORM } from './new-workspace'
export function resolveSourceControlLaunchPlatform(args: {
connectionId?: string | null
worktreePath?: string | null
}): NodeJS.Platform {
const path = args.worktreePath?.trim() ?? ''
if (typeof args.connectionId === 'string') {
return path && isWindowsAbsolutePathLike(path) && !isWslUncPath(path) ? 'win32' : 'linux'
}
if (path && isWslUncPath(path)) {
return 'linux'
}
return CLIENT_PLATFORM
}
+32 -11
View File
@@ -42,9 +42,8 @@ type RepoUpdate = Partial<
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'projectGroupId'
| 'projectGroupOrder'
| 'sourceControlAi'
>
>
> & { sourceControlAi?: Repo['sourceControlAi'] | null }
type NestedRepoScanControls = {
scanId?: string
@@ -663,16 +662,38 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
try {
const sanitizedUpdates = sanitizeRepoUpdate(updates)
const target = getActiveRuntimeTarget(get().settings)
await (target.kind === 'local'
? window.api.repos.update({ repoId: projectId, updates: sanitizedUpdates })
: callRuntimeRpc(
target,
'repo.update',
{ repo: projectId, updates: sanitizedUpdates },
{ timeoutMs: 15_000 }
))
const updatedRepo =
target.kind === 'local'
? await window.api.repos.update({ repoId: projectId, updates: sanitizedUpdates })
: (
await callRuntimeRpc<{ repo: Repo }>(
target,
'repo.update',
{ repo: projectId, updates: sanitizedUpdates },
{ timeoutMs: 15_000 }
)
).repo
set((s) => ({
repos: s.repos.map((r) => (r.id === projectId ? { ...r, ...sanitizedUpdates } : r))
repos: s.repos.map((r) => {
if (r.id !== projectId) {
return r
}
if (updatedRepo) {
return updatedRepo
}
if (sanitizedUpdates.sourceControlAi === null) {
const { sourceControlAi: _sourceControlAi, ...repoWithoutSourceControlAi } = r
const { sourceControlAi: _clearedSourceControlAi, ...updatesWithoutSourceControlAi } =
sanitizedUpdates
return { ...repoWithoutSourceControlAi, ...updatesWithoutSourceControlAi }
}
const { sourceControlAi, ...updatesWithoutSourceControlAi } = sanitizedUpdates
return {
...r,
...updatesWithoutSourceControlAi,
...(sourceControlAi !== undefined ? { sourceControlAi } : {})
}
})
}))
return true
} catch (err) {
@@ -0,0 +1,155 @@
import { describe, expect, it } from 'vitest'
import { getDefaultSettings } from './constants'
import {
normalizeWritableRepoSourceControlAiOverrides,
saveSourceControlActionRecipe,
toSourceControlAiRepoUpdate
} from './source-control-ai-recipe-save'
import type { GlobalSettings, Repo } from './types'
function settings(): GlobalSettings {
const base = getDefaultSettings('/tmp')
return {
...base,
defaultTuiAgent: 'codex',
sourceControlAi: {
...base.sourceControlAi!,
enabled: true,
agentId: 'codex',
selectedModelByAgent: { codex: 'gpt-5.5' },
selectedThinkingByModel: {}
}
}
}
describe('source-control AI recipe saves', () => {
it('writes complete repo recipes and removes same-action legacy instructions', () => {
const result = saveSourceControlActionRecipe({
target: { type: 'repo', repoId: 'repo-1' },
settings: settings(),
repo: {
sourceControlAi: {
enabled: false,
instructionsByOperation: {
commitMessage: 'legacy commit',
pullRequest: 'legacy review'
},
prCreationDefaults: {
draft: true,
useTemplate: null
}
}
} satisfies Pick<Repo, 'sourceControlAi'>,
actionId: 'commitMessage',
recipe: {
agentId: 'claude',
commandInputTemplate: '{basePrompt}\n\nrepo',
agentArgs: ' --model sonnet '
}
})
expect(result).toEqual({
target: { type: 'repo', repoId: 'repo-1' },
update: {
sourceControlAi: {
enabled: false,
instructionsByOperation: {
pullRequest: 'legacy review'
},
actionOverrides: {
commitMessage: {
agentId: 'claude',
commandInputTemplate: '{basePrompt}\n\nrepo',
agentArgs: '--model sonnet'
},
pullRequest: {
agentId: null,
commandInputTemplate: '{basePrompt}\n\nlegacy review'
}
},
prCreationDefaults: {
draft: true
}
}
}
})
})
it('clears empty repo source-control AI writes with the JSON boundary sentinel', () => {
expect(toSourceControlAiRepoUpdate({ prCreationDefaults: { draft: null } })).toEqual({
sourceControlAi: null
})
})
it('preserves boolean-only hosted-review defaults and strips legacy null defaults on write', () => {
expect(
normalizeWritableRepoSourceControlAiOverrides({
prCreationDefaults: {
draft: true,
useTemplate: null,
generateDetailsOnOpen: false
}
})
).toEqual({
prCreationDefaults: {
draft: true,
generateDetailsOnOpen: false
}
})
})
it('stores repo custom commands when saving a custom-command recipe', () => {
const result = saveSourceControlActionRecipe({
target: { type: 'repo', repoId: 'repo-1' },
settings: settings(),
repo: null,
actionId: 'commitMessage',
recipe: {
agentId: 'custom',
commandInputTemplate: '{basePrompt}'
},
customAgentCommand: 'repo-agent {prompt}'
})
expect(result).toMatchObject({
update: {
sourceControlAi: {
customAgentCommand: 'repo-agent {prompt}',
actionOverrides: {
commitMessage: {
agentId: 'custom',
commandInputTemplate: '{basePrompt}'
}
}
}
}
})
})
it('writes global recipes through a complete normalized source-control AI value', () => {
const result = saveSourceControlActionRecipe({
target: { type: 'global' },
settings: settings(),
actionId: 'pullRequest',
recipe: {
agentId: 'custom',
commandInputTemplate: '{basePrompt}\n\nreview',
agentArgs: '--verbose'
},
customAgentCommand: 'review-agent {prompt}'
})
expect(result.target).toEqual({ type: 'global' })
expect('sourceControlAi' in result).toBe(true)
if (!('sourceControlAi' in result)) {
throw new Error('Expected a global save result')
}
expect(result.sourceControlAi.actions?.pullRequest).toEqual({
agentId: 'custom',
commandInputTemplate: '{basePrompt}\n\nreview',
agentArgs: '--verbose'
})
expect(result.sourceControlAi.customAgentCommand).toBe('review-agent {prompt}')
expect(result.sourceControlAi.enabled).toBe(true)
})
})
+222
View File
@@ -0,0 +1,222 @@
import {
normalizeRepoSourceControlAiOverrides,
normalizeSourceControlAiSettings
} from './source-control-ai'
import {
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES,
SOURCE_CONTROL_ACTION_IDS,
SOURCE_CONTROL_TEXT_ACTION_IDS,
setSourceControlActionDefault,
type SourceControlActionId,
type SourceControlActionRecipe
} from './source-control-ai-actions'
import type {
CompleteSourceControlActionRecipe,
RepoSourceControlAiOverrides,
SourceControlAiModelChoice,
SourceControlAiOperation,
SourceControlAiSettings,
WritableRepoSourceControlAiOverrides
} from './source-control-ai-types'
import type { GlobalSettings, Repo } from './types'
export type SourceControlAiWriteTarget = { type: 'repo'; repoId: string } | { type: 'global' }
export type SourceControlAiRepoUpdate =
| { sourceControlAi: WritableRepoSourceControlAiOverrides }
| { sourceControlAi: null }
export type SourceControlActionRecipeSaveResult =
| { target: { type: 'global' }; sourceControlAi: SourceControlAiSettings }
| { target: { type: 'repo'; repoId: string }; update: SourceControlAiRepoUpdate }
type SaveSourceControlActionRecipeInput = {
target: SourceControlAiWriteTarget
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'>
repo?: Pick<Repo, 'sourceControlAi'> | null
actionId: SourceControlActionId
recipe: SourceControlActionRecipe
customAgentCommand?: string
}
type ReadCompatibleActionRecipe = {
agentId?: SourceControlActionRecipe['agentId']
commandInputTemplate?: string | null
agentArgs?: string | null
}
const TEXT_ACTION_ID_SET = new Set<SourceControlActionId>(SOURCE_CONTROL_TEXT_ACTION_IDS)
function hasEntries(value: Record<string, unknown> | null | undefined): boolean {
return Object.keys(value ?? {}).length > 0
}
function normalizeStringRecord<T extends string>(
value: Partial<Record<T, string | null | undefined>> | undefined
): Partial<Record<T, string>> | undefined {
const normalized: Partial<Record<T, string>> = {}
for (const [key, item] of Object.entries(value ?? {}) as [T, string | null | undefined][]) {
if (typeof item === 'string') {
normalized[key] = item
}
}
return hasEntries(normalized) ? normalized : undefined
}
function normalizeBooleanRecord<T extends string>(
value: Partial<Record<T, boolean | null | undefined>> | undefined
): Partial<Record<T, boolean>> | undefined {
const normalized: Partial<Record<T, boolean>> = {}
for (const [key, item] of Object.entries(value ?? {}) as [T, boolean | null | undefined][]) {
if (typeof item === 'boolean') {
normalized[key] = item
}
}
return hasEntries(normalized) ? normalized : undefined
}
function normalizeCompleteRecipe(
actionId: SourceControlActionId,
recipe: ReadCompatibleActionRecipe | undefined
): CompleteSourceControlActionRecipe | undefined {
if (!recipe) {
return undefined
}
const commandInputTemplate =
typeof recipe.commandInputTemplate === 'string'
? recipe.commandInputTemplate
: DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId]
const agentArgs = typeof recipe.agentArgs === 'string' ? recipe.agentArgs.trim() : ''
return {
agentId: recipe.agentId ?? null,
commandInputTemplate,
...(agentArgs ? { agentArgs } : {})
}
}
function normalizeActionOverrides(
overrides: RepoSourceControlAiOverrides['actionOverrides']
): WritableRepoSourceControlAiOverrides['actionOverrides'] {
const normalized: WritableRepoSourceControlAiOverrides['actionOverrides'] = {}
for (const actionId of SOURCE_CONTROL_ACTION_IDS) {
const recipe = normalizeCompleteRecipe(actionId, overrides?.[actionId])
if (recipe) {
normalized[actionId] = recipe
}
}
return hasEntries(normalized) ? normalized : undefined
}
export function normalizeWritableRepoSourceControlAiOverrides(
value: RepoSourceControlAiOverrides | null | undefined
): WritableRepoSourceControlAiOverrides | undefined {
const readCompatible = normalizeRepoSourceControlAiOverrides(value)
if (!readCompatible) {
return undefined
}
const writable: WritableRepoSourceControlAiOverrides = {}
if (typeof readCompatible.enabled === 'boolean') {
writable.enabled = readCompatible.enabled
}
if (typeof readCompatible.customAgentCommand === 'string') {
const customAgentCommand = readCompatible.customAgentCommand.trim()
if (customAgentCommand) {
writable.customAgentCommand = customAgentCommand
}
}
if (readCompatible.modelOverridesByOperation) {
writable.modelOverridesByOperation = readCompatible.modelOverridesByOperation as Partial<
Record<SourceControlAiOperation, SourceControlAiModelChoice>
>
}
const instructionsByOperation = normalizeStringRecord(readCompatible.instructionsByOperation)
if (instructionsByOperation) {
writable.instructionsByOperation = instructionsByOperation
}
const actionOverrides = normalizeActionOverrides(readCompatible.actionOverrides)
if (actionOverrides) {
writable.actionOverrides = actionOverrides
}
const prCreationDefaults = normalizeBooleanRecord(readCompatible.prCreationDefaults)
if (prCreationDefaults) {
writable.prCreationDefaults = prCreationDefaults
}
return Object.keys(writable).length > 0 ? writable : undefined
}
export function toSourceControlAiRepoUpdate(
value: RepoSourceControlAiOverrides | null | undefined
): SourceControlAiRepoUpdate {
const sourceControlAi = normalizeWritableRepoSourceControlAiOverrides(value)
return sourceControlAi ? { sourceControlAi } : { sourceControlAi: null }
}
function dropLegacyInstructionForAction(
value: WritableRepoSourceControlAiOverrides,
actionId: SourceControlActionId
): WritableRepoSourceControlAiOverrides {
if (!TEXT_ACTION_ID_SET.has(actionId) || !value.instructionsByOperation) {
return value
}
const instructionsByOperation = { ...value.instructionsByOperation }
delete instructionsByOperation[actionId as SourceControlAiOperation]
return {
...value,
instructionsByOperation: hasEntries(instructionsByOperation)
? instructionsByOperation
: undefined
}
}
function normalizeRecipeForSave(
actionId: SourceControlActionId,
recipe: SourceControlActionRecipe
): CompleteSourceControlActionRecipe {
return (
normalizeCompleteRecipe(actionId, recipe) ?? {
agentId: null,
commandInputTemplate: DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId]
}
)
}
export function saveSourceControlActionRecipe(
input: SaveSourceControlActionRecipeInput
): SourceControlActionRecipeSaveResult {
const savedRecipe = normalizeRecipeForSave(input.actionId, input.recipe)
if (input.target.type === 'global') {
const current = normalizeSourceControlAiSettings(
input.settings.sourceControlAi,
input.settings.commitMessageAi
)
return {
target: { type: 'global' },
sourceControlAi: {
...current,
...(typeof input.customAgentCommand === 'string'
? { customAgentCommand: input.customAgentCommand }
: {}),
actions: setSourceControlActionDefault(current.actions, input.actionId, savedRecipe)
}
}
}
const currentRepoAi = normalizeWritableRepoSourceControlAiOverrides(input.repo?.sourceControlAi)
const next = dropLegacyInstructionForAction(
{
...currentRepoAi,
...(typeof input.customAgentCommand === 'string'
? { customAgentCommand: input.customAgentCommand }
: {}),
actionOverrides: {
...currentRepoAi?.actionOverrides,
[input.actionId]: savedRecipe
}
},
input.actionId
)
return {
target: input.target,
update: toSourceControlAiRepoUpdate(next)
}
}
+17
View File
@@ -45,6 +45,8 @@ export type SourceControlAiSettingsPatch =
| ((current: SourceControlAiSettings) => Partial<SourceControlAiSettings>)
export type RepoSourceControlAiOverrides = {
enabled?: boolean
customAgentCommand?: string
modelOverridesByOperation?: Partial<Record<SourceControlAiOperation, SourceControlAiModelChoice>>
instructionsByOperation?: Partial<Record<SourceControlAiOperation, string | null>>
actionOverrides?: Partial<
@@ -64,3 +66,18 @@ export type RepoSourceControlAiOverrides = {
openAfterCreate?: boolean | null
}
}
export type CompleteSourceControlActionRecipe = {
agentId: TuiAgent | CustomAgentId | null
commandInputTemplate: string
agentArgs?: string
}
export type WritableRepoSourceControlAiOverrides = {
enabled?: boolean
customAgentCommand?: string
modelOverridesByOperation?: Partial<Record<SourceControlAiOperation, SourceControlAiModelChoice>>
instructionsByOperation?: Partial<Record<SourceControlAiOperation, string>>
actionOverrides?: Partial<Record<SourceControlActionId, CompleteSourceControlActionRecipe>>
prCreationDefaults?: SourceControlAiPrCreationDefaults
}
+53
View File
@@ -6,6 +6,7 @@ import {
mergeLegacyCommitMessageAiIntoSourceControlAi,
normalizeRepoSourceControlAiOverrides,
projectSourceControlAiToLegacyCommitMessageAi,
resolveSourceControlAiEnabled,
readSourceControlAiModelChoiceForHost,
resolveSourceControlAiForOperation,
resolveSourceControlAiPrCreationDefaults,
@@ -111,6 +112,30 @@ describe('source-control AI resolution', () => {
})
})
it('lets repo enablement override the global default', () => {
const base = settings()
base.sourceControlAi = {
...base.sourceControlAi!,
enabled: false
}
expect(resolveSourceControlAiEnabled({ settings: base, repo: null })).toBe(false)
expect(
resolveSourceControlAiEnabled({
settings: base,
repo: { sourceControlAi: { enabled: true } }
})
).toBe(true)
base.sourceControlAi.enabled = true
expect(
resolveSourceControlAiEnabled({
settings: base,
repo: { sourceControlAi: { enabled: false } }
})
).toBe(false)
})
it('resolves PR defaults even when generation config is invalid', () => {
const base = settings()
base.sourceControlAi = {
@@ -203,6 +228,34 @@ describe('source-control AI resolution', () => {
expect(result.ok && result.value.params.model).toBe('gpt-5.4-mini')
})
it('uses the repo custom command before the global custom command', () => {
const base = settings()
base.sourceControlAi = {
...base.sourceControlAi!,
actions: {
...base.sourceControlAi!.actions,
commitMessage: {
agentId: 'custom',
commandInputTemplate: '{basePrompt}'
}
},
customAgentCommand: 'global-agent {prompt}'
}
const result = resolveSourceControlAiForOperation({
settings: base,
repo: {
sourceControlAi: {
customAgentCommand: 'repo-agent {prompt}'
}
},
operation: 'commitMessage',
discoveryHostKey: 'local'
})
expect(result.ok && result.value.params.customAgentCommand).toBe('repo-agent {prompt}')
})
it('resolves thinking effort with override precedence and model default fallback', () => {
expect(resolve('commitMessage').params.thinkingLevel).toBe('medium')
expect(
+27 -4
View File
@@ -245,6 +245,15 @@ export function normalizeRepoSourceControlAiOverrides(
return undefined
}
const normalized: RepoSourceControlAiOverrides = {}
if (typeof value.enabled === 'boolean') {
normalized.enabled = value.enabled
}
if (typeof value.customAgentCommand === 'string') {
const customAgentCommand = value.customAgentCommand.trim()
if (customAgentCommand) {
normalized.customAgentCommand = customAgentCommand
}
}
const modelOverridesByOperation = normalizeOperationRecord(
value.modelOverridesByOperation,
normalizeSourceControlAiModelChoice
@@ -297,7 +306,7 @@ export function normalizeRepoSourceControlAiOverrides(
if (prCreationDefaults) {
normalized.prCreationDefaults = prCreationDefaults
}
return normalized
return Object.keys(normalized).length > 0 ? normalized : undefined
}
function commandTemplateFromInstruction(instruction: string | null | undefined): string {
@@ -1080,6 +1089,18 @@ export function resolveSourceControlAiPrCreationDefaults(
)
}
export function resolveSourceControlAiEnabled(input: {
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'> | null | undefined
repo?: Pick<Repo, 'sourceControlAi'> | null
}): boolean {
const source = normalizeSourceControlAiSettings(
input.settings?.sourceControlAi,
input.settings?.commitMessageAi
)
const repoOverrides = normalizeRepoSourceControlAiOverrides(input.repo?.sourceControlAi)
return repoOverrides?.enabled ?? source.enabled
}
export function resolveSourceControlActionRecipe(input: {
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'> | null | undefined
repo?: Pick<Repo, 'sourceControlAi'> | null
@@ -1116,14 +1137,14 @@ export function resolveSourceControlAiForOperation(
): ResolveSourceControlAiResult {
const legacy = input.settings.commitMessageAi
const source = normalizeSourceControlAiSettings(input.settings.sourceControlAi, legacy)
if (!source.enabled) {
const repoOverrides = normalizeRepoSourceControlAiOverrides(input.repo?.sourceControlAi)
if (!(repoOverrides?.enabled ?? source.enabled)) {
return {
ok: false,
error: 'Enable Source Control AI in Settings -> Git.'
}
}
const repoOverrides = normalizeRepoSourceControlAiOverrides(input.repo?.sourceControlAi)
const prCreationDefaults = resolvePrCreationDefaults(
source,
repoOverrides,
@@ -1152,8 +1173,9 @@ export function resolveSourceControlAiForOperation(
}
}
const customAgentCommand =
repoOverrides?.customAgentCommand?.trim() || source.customAgentCommand.trim()
if (isCustomAgentId(agentChoice)) {
const customAgentCommand = source.customAgentCommand.trim()
if (!customAgentCommand) {
return {
ok: false,
@@ -1249,6 +1271,7 @@ export function resolveSourceControlAiForOperation(
),
commandInputTemplate: actionRecipe.commandInputTemplate,
...(actionRecipe.agentArgs !== undefined ? { agentArgs: actionRecipe.agentArgs } : {}),
...(customAgentCommand ? { customAgentCommand } : {}),
...(agentCommandOverride ? { agentCommandOverride } : {})
},
prCreationDefaults